From bf4c21d7ee190c4a455d62d69d867924b8d86077 Mon Sep 17 00:00:00 2001 From: Ernest Wong Date: Wed, 21 Mar 2018 11:38:20 -0700 Subject: [PATCH 1/5] Alias 0.3.0 --- src/alias/azext_alias/__init__.py | 57 +++-- src/alias/azext_alias/_const.py | 10 +- src/alias/azext_alias/_help.py | 48 +++++ src/alias/azext_alias/alias.py | 114 +++++----- src/alias/azext_alias/custom.py | 202 ++++++++++++++++++ src/alias/azext_alias/tests/test_alias.py | 24 ++- .../azext_alias/tests/test_alias_commands.py | 138 ++++++++++++ src/alias/azext_alias/tests/test_argument.py | 3 - src/alias/azext_alias/tests/test_custom.py | 117 ++++++++++ src/alias/azext_alias/version.py | 2 +- src/index.json | 6 +- 11 files changed, 637 insertions(+), 84 deletions(-) create mode 100644 src/alias/azext_alias/_help.py create mode 100644 src/alias/azext_alias/custom.py create mode 100644 src/alias/azext_alias/tests/test_alias_commands.py create mode 100644 src/alias/azext_alias/tests/test_custom.py diff --git a/src/alias/azext_alias/__init__.py b/src/alias/azext_alias/__init__.py index 6a6d1baf3de..62da0fda396 100644 --- a/src/alias/azext_alias/__init__.py +++ b/src/alias/azext_alias/__init__.py @@ -3,37 +3,65 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +# pylint: disable=line-too-long + import timeit from knack.log import get_logger from azure.cli.core import AzCommandsLoader -from azure.cli.core.commands import CliCommandType +from azure.cli.core.decorators import Completer from azure.cli.core.commands.events import EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE -from azext_alias.alias import AliasManager +from azext_alias.alias import ( + GLOBAL_ALIAS_PATH, + AliasManager, + get_config_parser +) from azext_alias._const import DEBUG_MSG_WITH_TIMING from azext_alias import telemetry +from azext_alias import _help # pylint: disable=unused-import logger = get_logger(__name__) +cached_reserved_commands = [] -class AliasExtensionLoader(AzCommandsLoader): +class AliasCommandLoader(AzCommandsLoader): def __init__(self, cli_ctx=None): - super(AliasExtensionLoader, self).__init__(cli_ctx=cli_ctx, - custom_command_type=CliCommandType()) - + from azure.cli.core.commands import CliCommandType + custom_command_type = CliCommandType(operations_tmpl='azext_alias.custom#{}') + super(AliasCommandLoader, self).__init__(cli_ctx=cli_ctx, + custom_command_type=custom_command_type) self.cli_ctx.register_event(EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, alias_event_handler) - def load_command_table(self, _): # pylint:disable=no-self-use - return {} + def load_command_table(self, _): + with self.command_group('alias') as g: + g.custom_command('create', 'create_alias') + g.custom_command('list', 'list_alias') + g.custom_command('remove', 'remove_alias') - def load_arguments(self, _): # pylint:disable=no-self-use - pass + return self.command_table + + def load_arguments(self, _): + with self.argument_context('alias') as c: + c.argument('alias_name', options_list=['--name', '-n'], help='The name of the alias.', completer=get_alias_completer) + c.argument('alias_command', options_list=['--command', '-c'], help='The command that the alias points to.') + + +@Completer +def get_alias_completer(cmd, prefix, namespace, **kwargs): # pylint: disable=unused-argument + try: + alias_table = get_config_parser() + alias_table.read(GLOBAL_ALIAS_PATH) + return alias_table.sections() + except Exception: # pylint: disable=broad-except + return [] def alias_event_handler(_, **kwargs): - """ An event handler for alias transformation when EVENT_INVOKER_PRE_TRUNCATE_CMD_TBL event is invoked """ + """ + An event handler for alias transformation when EVENT_INVOKER_PRE_TRUNCATE_CMD_TBL event is invoked + """ try: telemetry.start() @@ -44,6 +72,11 @@ def alias_event_handler(_, **kwargs): # [:] will keep the reference of the original args args[:] = alias_manager.transform(args) + # Cache the reserved commands for validation later + if args[:2] == ['alias', 'create']: + global cached_reserved_commands # pylint: disable=global-statement + cached_reserved_commands = alias_manager.reserved_commands if alias_manager.reserved_commands else kwargs.get('load_cmd_tbl_func')([]).keys() + elapsed_time = (timeit.default_timer() - start_time) * 1000 logger.debug(DEBUG_MSG_WITH_TIMING, args, elapsed_time) @@ -55,4 +88,4 @@ def alias_event_handler(_, **kwargs): telemetry.conclude() -COMMAND_LOADER_CLS = AliasExtensionLoader +COMMAND_LOADER_CLS = AliasCommandLoader diff --git a/src/alias/azext_alias/_const.py b/src/alias/azext_alias/_const.py index be8fd5c4629..45b19ece9d2 100644 --- a/src/alias/azext_alias/_const.py +++ b/src/alias/azext_alias/_const.py @@ -9,10 +9,10 @@ ALIAS_FILE_NAME = 'alias' ALIAS_HASH_FILE_NAME = 'alias.sha1' COLLIDED_ALIAS_FILE_NAME = 'collided_alias' -COLLISION_CHECK_LEVEL_DEPTH = 4 +COLLISION_CHECK_LEVEL_DEPTH = 5 INSUFFICIENT_POS_ARG_ERROR = 'alias: "{}" takes exactly {} positional argument{} ({} given)' -CONFIG_PARSING_ERROR = 'alias: Error parsing the configuration file - {}. Please fix the problem manually.' +CONFIG_PARSING_ERROR = 'alias: Error parsing the configuration file - %s. Please fix the problem manually.' DEBUG_MSG = 'Alias Manager: Transforming "%s" to "%s"' DEBUG_MSG_WITH_TIMING = 'Alias Manager: Transformed args to %s in %.3fms' POS_ARG_DEBUG_MSG = 'Alias Manager: Transforming "%s" to "%s", with the following positional arguments: %s' @@ -20,3 +20,9 @@ RENDER_TEMPLATE_ERROR = 'alias: Encounted the following error when injecting positional arguments to "{}" - {}' PLACEHOLDER_EVAL_ERROR = 'alias: Encounted the following error when evaluating "{}" - {}' PLACEHOLDER_BRACKETS_ERROR = 'alias: Brackets in "{}" are not enclosed properly' +ALIAS_NOT_FOUND_ERROR = 'alias: "{}" alias not found' +INVALID_ALIAS_COMMAND_ERROR = 'alias: Invalid Azure CLI command "{}"' +EMPTY_ALIAS_ERROR = 'alias: Empty alias name or command is invalid' +INVALID_STARTING_CHAR_ERROR = 'alias: Alias name should not start with "{}"' +INCONSISTENT_ARG_ERROR = 'alias: Positional argument{} {} {} not in both alias name and alias command' +COMMAND_LVL_ERROR = 'alias: "{}" is a reserved command and cannot be used to represent "{}"' diff --git a/src/alias/azext_alias/_help.py b/src/alias/azext_alias/_help.py new file mode 100644 index 00000000000..f8caafdf405 --- /dev/null +++ b/src/alias/azext_alias/_help.py @@ -0,0 +1,48 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.help_files import helps # pylint: disable=unused-import + + +helps['alias'] = """ + type: group + short-summary: Manage Azure CLI Aliases. +""" + + +helps['alias create'] = """ + type: command + short-summary: Create an alias. + examples: + - name: Create a simple alias. + text: > + az alias create --name rg --command group\n + az alias create --name ls --command list + - name: Create a complex alias. + text: > + az alias create --name list-vm --command 'vm list --resource-group myResourceGroup' + + - name: Create an alias with positional arguments. + text: > + az alias create --name 'list-vm {{ resource_group }}' --command 'vm list --resource-group {{ resource_group }}' + + - name: Create an alias with positional arguments and additional string processing. + text: > + az alias create --name 'storage-ls {{ url }}' --command 'storage blob list \n + --account-name {{ url.replace("https://", "").split(".")[0] }}\n + --container-name {{ url.replace("https://", "").split("/")[1] }}' +""" + + +helps['alias list'] = """ + type: command + short-summary: List the registered aliases. +""" + + +helps['alias remove'] = """ + type: command + short-summary: Remove an alias. +""" diff --git a/src/alias/azext_alias/alias.py b/src/alias/azext_alias/alias.py index 7569fd0ad12..e74fe02b40f 100644 --- a/src/alias/azext_alias/alias.py +++ b/src/alias/azext_alias/alias.py @@ -131,25 +131,29 @@ def transform(self, args): """ if self.parse_error(): # Write an empty hash so next run will check the config file against the entire command table again - self.write_alias_config_hash(True) + AliasManager.write_alias_config_hash(empty_hash=True) return args # Only load the entire command table if it detects changes in the alias config if self.detect_alias_config_change(): self.load_full_command_table() - self.build_collision_table() + self.collided_alias = AliasManager.build_collision_table(self.alias_table.sections(), + self.reserved_commands) else: self.load_collided_alias() transformed_commands = [] alias_iter = enumerate(args, 1) - + ignore_next_iter = False for alias_index, alias in alias_iter: # Directly append invalid alias or collided alias - if not alias or alias[0] == '-' or (alias in self.collided_alias and - alias_index in self.collided_alias[alias]): + if not alias or alias[0] == '-' or ignore_next_iter or (alias in self.collided_alias and + alias_index in self.collided_alias[alias]): transformed_commands.append(alias) + ignore_next_iter = alias and alias[0] == '-' continue + else: + ignore_next_iter = False full_alias = self.get_full_alias(alias) @@ -174,35 +178,6 @@ def transform(self, args): return self.post_transform(transformed_commands) - def build_collision_table(self, levels=COLLISION_CHECK_LEVEL_DEPTH): - """ - Build the collision table according to the alias configuration file against the entire command table. - - self.collided_alias is structured as: - { - 'collided_alias': [the command level at which collision happens] - } - For example: - { - 'account': [1, 2] - } - This means that 'account' is a reserved command in level 1 and level 2 of the command tree because - (az account ...) and (az storage account ...) - lvl 1 lvl 2 - - Args: - levels: the amount of levels we tranverse through the command table tree. - """ - for alias in self.alias_table.sections(): - # Only care about the first word in the alias because alias - # cannot have spaces (unless they have positional arguments) - word = alias.split()[0] - for level in range(1, levels + 1): - collision_regex = r'^{}{}($|\s)'.format(r'([a-z\-]*\s)' * (level - 1), word.lower()) - if list(filter(re.compile(collision_regex).match, self.reserved_commands)): - self.collided_alias[word].append(level) - telemetry.set_collided_aliases(list(self.collided_alias.keys())) - def get_full_alias(self, query): """ Get the full alias given a search query. @@ -240,12 +215,58 @@ def post_transform(self, args): for arg in args: post_transform_commands.append(os.path.expandvars(arg)) - self.write_alias_config_hash() - self.write_collided_alias() + AliasManager.write_alias_config_hash(self.alias_config_hash) + AliasManager.write_collided_alias(self.collided_alias) return post_transform_commands - def write_alias_config_hash(self, empty_hash=False): + def parse_error(self): + """ + Check if there is a configuration parsing error. + + A parsing error has occurred if there are strings inside the alias config file + but there is no alias loaded in self.alias_table. + + Returns: + True if there is an error parsing the alias configuration file. Otherwises, false. + """ + return not self.alias_table.sections() and self.alias_config_str + + @staticmethod + def build_collision_table(aliases, reserved_commands, levels=COLLISION_CHECK_LEVEL_DEPTH): + """ + Build the collision table according to the alias configuration file against the entire command table. + + self.collided_alias is structured as: + { + 'collided_alias': [the command level at which collision happens] + } + For example: + { + 'account': [1, 2] + } + This means that 'account' is a reserved command in level 1 and level 2 of the command tree because + (az account ...) and (az storage account ...) + lvl 1 lvl 2 + + Args: + levels: the amount of levels we tranverse through the command table tree. + """ + collided_alias = defaultdict(list) + for alias in aliases: + # Only care about the first word in the alias because alias + # cannot have spaces (unless they have positional arguments) + word = alias.split()[0] + for level in range(1, levels + 1): + collision_regex = r'^{}{}($|\s)'.format(r'([a-z\-]*\s)' * (level - 1), word.lower()) + if list(filter(re.compile(collision_regex).match, reserved_commands)): + collided_alias[word].append(level) + + telemetry.set_collided_aliases(list(collided_alias.keys())) + return collided_alias + + @staticmethod + def write_alias_config_hash(alias_config_hash='', empty_hash=False): """ Write self.alias_config_hash to the alias hash file. @@ -254,9 +275,10 @@ def write_alias_config_hash(self, empty_hash=False): means that we have to perform a full load of the command table in the next run. """ with open(GLOBAL_ALIAS_HASH_PATH, 'w') as alias_config_hash_file: - alias_config_hash_file.write('' if empty_hash else self.alias_config_hash) + alias_config_hash_file.write('' if empty_hash else alias_config_hash) - def write_collided_alias(self): + @staticmethod + def write_collided_alias(collided_alias_dict): """ Write the collided aliases string into the collided alias file. """ @@ -264,19 +286,7 @@ def write_collided_alias(self): open_mode = 'r+' if os.path.exists(GLOBAL_COLLIDED_ALIAS_PATH) else 'w+' with open(GLOBAL_COLLIDED_ALIAS_PATH, open_mode) as collided_alias_file: collided_alias_file.truncate() - collided_alias_file.write(json.dumps(self.collided_alias)) - - def parse_error(self): - """ - Check if there is a configuration parsing error. - - A parsing error has occurred if there are strings inside the alias config file - but there is no alias loaded in self.alias_table. - - Returns: - True if there is an error parsing the alias configuration file. Otherwises, false. - """ - return not self.alias_table.sections() and self.alias_config_str + collided_alias_file.write(json.dumps(collided_alias_dict)) @staticmethod def process_exception_message(exception): diff --git a/src/alias/azext_alias/custom.py b/src/alias/azext_alias/custom.py new file mode 100644 index 00000000000..6aee720b181 --- /dev/null +++ b/src/alias/azext_alias/custom.py @@ -0,0 +1,202 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import re +import shlex +import hashlib + +from knack.util import CLIError + +import azext_alias +from azext_alias.argument import get_placeholders +from azext_alias._const import ( + COLLISION_CHECK_LEVEL_DEPTH, + ALIAS_NOT_FOUND_ERROR, + INVALID_ALIAS_COMMAND_ERROR, + EMPTY_ALIAS_ERROR, + INVALID_STARTING_CHAR_ERROR, + INCONSISTENT_ARG_ERROR, + COMMAND_LVL_ERROR +) +from azext_alias.alias import ( + GLOBAL_ALIAS_PATH, + get_config_parser, + AliasManager +) + + +def create_alias(alias_name, alias_command): + """ + Create an alias. + + Args: + alias_name: The name of the alias. + alias_command: The command that the alias points to. + """ + alias_name, alias_command = alias_name.strip(), alias_command.strip() + _validate_alias_name(alias_name) + _validate_alias_command(alias_command) + _validate_alias_command_level(alias_name, alias_command) + _validate_pos_args_syntax(alias_name, alias_command) + + alias_table = _get_alias_table() + if alias_name not in alias_table.sections(): + alias_table.add_section(alias_name) + alias_table.set(alias_name, 'command', alias_command) + _commit_change(alias_table) + + +def list_alias(): + """ + List all registered aliases. + + Returns: + An array of dictionary containing the alias and the command that it points to. + """ + alias_table = _get_alias_table() + output = [] + for alias in alias_table.sections(): + if alias_table.has_option(alias, 'command'): + output.append({ + 'alias': alias, + # Remove unnecessary whitespaces + 'command': ' '.join(alias_table.get(alias, 'command').split()) + }) + + return output + + +def remove_alias(alias_name): + """ + Remove an alias. + + Args: + alias_name: The name of the alias to be removed. + """ + alias_table = _get_alias_table() + if alias_name not in alias_table.sections(): + raise CLIError(ALIAS_NOT_FOUND_ERROR.format(alias_name)) + alias_table.remove_section(alias_name) + _commit_change(alias_table) + + +def _get_alias_table(): + """ + Get the current alias table. + """ + try: + alias_table = get_config_parser() + alias_table.read(GLOBAL_ALIAS_PATH) + return alias_table + except Exception: # pylint: disable=broad-except + # Exception is being handled by self.load_alias_table in alias.py + # so simply raise an empty CLIError to terminate all actions + raise CLIError() + + +def _commit_change(alias_table): + """ + Record changes to the alias table. + Also write new alias config hash and collided alias, if any. + + Args: + alias_table: The alias table to commit. + """ + with open(GLOBAL_ALIAS_PATH, 'w+') as alias_config_file: + alias_table.write(alias_config_file) + alias_config_file.seek(0) + alias_config_hash = hashlib.sha1(alias_config_file.read().encode('utf-8')).hexdigest() + AliasManager.write_alias_config_hash(alias_config_hash) + + collided_alias = AliasManager.build_collision_table(alias_table.sections(), azext_alias.cached_reserved_commands) + AliasManager.write_collided_alias(collided_alias) + + +def _validate_alias_name(alias_name): + """ + Check if the alias name is valid. + + Args: + alias_name: The name of the alias to validate. + """ + if not alias_name: + raise CLIError(EMPTY_ALIAS_ERROR) + + if not re.match('^[a-zA-Z]', alias_name): + raise CLIError(INVALID_STARTING_CHAR_ERROR.format(alias_name[0])) + + +def _validate_alias_command(alias_command): + """ + Check if the alias command is valid. + + Args: + alias_command: The command to validate. + """ + if not alias_command: + raise CLIError(EMPTY_ALIAS_ERROR) + + # Boundary index is the index at which named argument or positional argument starts + split_command = shlex.split(alias_command) + boundary_index = len(split_command) + for i, subcommand in enumerate(split_command): + if not re.match('^[a-z]', subcommand.lower()) or i > COLLISION_CHECK_LEVEL_DEPTH: + boundary_index = i + break + + # Extract possible CLI commands and validate + command_to_validate = ' '.join(split_command[:boundary_index]).lower() + for command in azext_alias.cached_reserved_commands: + if re.match(r'([a-z\-]*\s)*{}($|\s)'.format(command_to_validate), command): + return + + raise CLIError(INVALID_ALIAS_COMMAND_ERROR.format(command_to_validate if command_to_validate else alias_command)) + + +def _validate_pos_args_syntax(alias_name, alias_command): + """ + Check if the positional argument syntax is valid in alias name and alias command. + + Args: + alias_name: The name of the alias to validate. + alias_command: The command to validate. + """ + pos_args_from_alias = get_placeholders(alias_name) + # Split by '|' to extract positional argument name from Jinja filter (e.g. {{ arg_name | upper }}) + # Split by '.' to extract positional argument name from function call (e.g. {{ arg_name.split()[0] }}) + pos_args_from_command = [x.split('|')[0].split('.')[0].strip() for x in get_placeholders(alias_command)] + + if set(pos_args_from_alias) != set(pos_args_from_command): + arg_diff = set(pos_args_from_alias) ^ set(pos_args_from_command) + raise CLIError(INCONSISTENT_ARG_ERROR.format('' if len(arg_diff) == 1 else 's', + arg_diff, + 'is' if len(arg_diff) == 1 else 'are')) + + +def _validate_alias_command_level(alias, command): + """ + Make sure that if the alias is a reserved command, the command that the alias points to + in the command tree does not conflict in levels. + + e.g. 'dns' -> 'network dns' is valid because dns is a level 2 command and network dns starts at level 1. + However, 'list' -> 'show' is not valid because list and show are both reserved commands at level 2. + + Args: + alias: The name of the alias. + command: The command that the alias points to. + """ + alias_collision_table = AliasManager.build_collision_table([alias], azext_alias.cached_reserved_commands) + + # Alias is not a reserved command, so it can point to any command + if not alias_collision_table: + return + + command_collision_table = AliasManager.build_collision_table([command], azext_alias.cached_reserved_commands) + alias_collision_levels = alias_collision_table.get(alias.split()[0], []) + command_collision_levels = command_collision_table.get(command.split()[0], []) + + # Check if there is a command level conflict + if set(alias_collision_levels) & set(command_collision_levels): + raise CLIError(COMMAND_LVL_ERROR.format(alias, command)) diff --git a/src/alias/azext_alias/tests/test_alias.py b/src/alias/azext_alias/tests/test_alias.py index 3af1b93460d..83976330bc0 100644 --- a/src/alias/azext_alias/tests/test_alias.py +++ b/src/alias/azext_alias/tests/test_alias.py @@ -5,10 +5,11 @@ # pylint: disable=line-too-long,import-error,no-self-use,deprecated-method,pointless-string-statement,relative-import,no-member,redefined-outer-name,too-many-return-statements -import sys import os +import sys import shlex import unittest +from mock import Mock from six.moves import configparser from knack.util import CLIError @@ -38,6 +39,7 @@ ('mn diag', 'monitor diagnostic-settings create'), ('create-vm', 'vm create -g test-group -n test-vm'), ('ac-ls', 'ac ls'), + ('-n ac', '-n ac'), ('-h', '-h'), ('storage-connect test1 test2', 'storage account connection-string -g test1 -n test2 -otsv'), ('', ''), @@ -89,7 +91,7 @@ def test_transform_alias(self, test_case): def test_transform_collided_alias(self, test_case): alias_manager = self.get_alias_manager(COLLISION_MOCK_ALIAS_STRING, TEST_RESERVED_COMMANDS) - alias_manager.build_collision_table() + alias_manager.collided_alias = alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), alias_manager.reserved_commands) self.assertEqual(shlex.split(test_case[1]), alias_manager.transform(shlex.split(test_case[0]))) @@ -143,14 +145,20 @@ def test(self): class TestAlias(unittest.TestCase): + @classmethod + def setUp(cls): + alias.AliasManager.write_alias_config_hash = Mock() + alias.AliasManager.write_collided_alias = Mock() + def test_build_empty_collision_table(self): alias_manager = self.get_alias_manager(DEFAULT_MOCK_ALIAS_STRING, TEST_RESERVED_COMMANDS) - self.assertDictEqual(dict(), alias_manager.collided_alias) + test_case = alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), alias_manager.reserved_commands) + self.assertDictEqual(dict(), test_case) def test_build_non_empty_collision_table(self): alias_manager = self.get_alias_manager(COLLISION_MOCK_ALIAS_STRING, TEST_RESERVED_COMMANDS) - alias_manager.build_collision_table(levels=2) - self.assertDictEqual({'account': [1, 2], 'dns': [2], 'list-locations': [2]}, alias_manager.collided_alias) + test_case = alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), alias_manager.reserved_commands, levels=2) + self.assertDictEqual({'account': [1, 2], 'dns': [2], 'list-locations': [2]}, test_case) def test_non_parse_error(self): alias_manager = self.get_alias_manager() @@ -207,12 +215,6 @@ def load_alias_hash(self): def load_collided_alias(self): pass - def write_alias_config_hash(self, empty_hash=False): - pass - - def write_collided_alias(self): - pass - # Inject data-driven tests into TestAlias class for test_type, test_cases in TEST_DATA.items(): diff --git a/src/alias/azext_alias/tests/test_alias_commands.py b/src/alias/azext_alias/tests/test_alias_commands.py new file mode 100644 index 00000000000..86a5e334a95 --- /dev/null +++ b/src/alias/azext_alias/tests/test_alias_commands.py @@ -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. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=line-too-long + +import os +import shutil +import tempfile +import unittest + +from azure.cli.testsdk import ScenarioTest +from azext_alias import ( + alias, + custom +) +from azext_alias._const import ( + ALIAS_FILE_NAME, + ALIAS_HASH_FILE_NAME, + COLLIDED_ALIAS_FILE_NAME +) + + +class AliasTests(ScenarioTest): + + def setUp(self): + self.mock_config_dir = tempfile.mkdtemp() + alias.GLOBAL_CONFIG_DIR = self.mock_config_dir + alias.GLOBAL_ALIAS_PATH = os.path.join(self.mock_config_dir, ALIAS_FILE_NAME) + alias.GLOBAL_ALIAS_HASH_PATH = os.path.join(self.mock_config_dir, ALIAS_HASH_FILE_NAME) + alias.GLOBAL_COLLIDED_ALIAS_PATH = os.path.join(self.mock_config_dir, COLLIDED_ALIAS_FILE_NAME) + custom.GLOBAL_ALIAS_PATH = os.path.join(self.mock_config_dir, ALIAS_FILE_NAME) + + def tearDown(self): + shutil.rmtree(self.mock_config_dir) + + def test_create_and_list_alias(self): + self.kwargs.update({ + 'alias_name': 'c', + 'alias_command': 'create' + }) + self.cmd('az alias create -n \'{alias_name}\' -c \'{alias_command}\'') + self.cmd('az alias list', checks=[ + self.check('[0].alias', '{alias_name}'), + self.check('[0].command', '{alias_command}'), + self.check('length(@)', 1) + ]) + + def test_create_and_list_alias_with_pos_arg(self): + self.kwargs.update({ + 'alias_name': 'list-vm {{ resource_group }}', + 'alias_command': 'vm list - -resource-group {{ resource_group }}' + }) + self.cmd('az alias create -n \'{alias_name}\' -c \'{alias_command}\'') + self.cmd('az alias list', checks=[ + self.check('[0].alias', '{alias_name}'), + self.check('[0].command', '{alias_command}'), + self.check('length(@)', 1) + ]) + self.kwargs.update({ + 'alias_name': 'storage-ls {{ url }}', + 'alias_command': 'storage blob list --account-name {{ url.replace("https://", "").split(".")[0] }} --container-name {{ url.replace("https://", "").split("/")[1] }}' + }) + self.cmd('az alias create -n \'{alias_name}\' -c \'{alias_command}\'') + self.cmd('az alias list', checks=[ + self.check('[1].alias', '{alias_name}'), + self.check('[1].command', '{alias_command}'), + self.check('length(@)', 2) + ]) + + def test_create_alias_error(self): + self.kwargs.update({ + 'alias_name': 'c', + 'alias_command': 'will_fail' + }) + self.cmd('az alias create -n \'{alias_name}\' -c \'{alias_command}\'', expect_failure=True) + self.cmd('az alias list', checks=[ + self.check('length(@)', 0) + ]) + + def test_remove_alias(self): + self.kwargs.update({ + 'alias_name': 'c', + 'alias_command': 'create' + }) + self.cmd('az alias create -n \'{alias_name}\' -c \'{alias_command}\'') + self.cmd('az alias list', checks=[ + self.check('[0].alias', '{alias_name}'), + self.check('[0].command', '{alias_command}'), + self.check('length(@)', 1) + ]) + self.cmd('az alias remove -n "{alias_name}"') + self.cmd('az alias list', checks=[ + self.check('length(@)', 0) + ]) + + def test_remove_alias_non_existing(self): + self.kwargs.update({ + 'alias_name': 'c', + }) + self.cmd('az alias list', checks=[ + self.check('length(@)', 0) + ]) + self.cmd('az alias remove -n "{alias_name}"', expect_failure=True) + + def test_alias_file_and_hash_create(self): + self.kwargs.update({ + 'alias_name': 'c', + 'alias_command': 'create' + }) + self.cmd('az alias create -n \'{alias_name}\' -c \'{alias_command}\'') + expected_alias_string = '''[c] +command = create + +''' + with open(alias.GLOBAL_ALIAS_PATH) as alias_config_file: + assert alias_config_file.read() == expected_alias_string + + def test_alias_file_and_hash_remove(self): + self.kwargs.update({ + 'alias_name': 'c', + 'alias_command': 'create' + }) + self.cmd('az alias create -n \'{alias_name}\' -c \'{alias_command}\'') + self.cmd('az alias list', checks=[ + self.check('[0].alias', '{alias_name}'), + self.check('[0].command', '{alias_command}'), + self.check('length(@)', 1) + ]) + self.cmd('az alias remove -n "{alias_name}"') + + with open(alias.GLOBAL_ALIAS_PATH) as alias_config_file: + assert not alias_config_file.read() + + +if __name__ == '__main__': + unittest.main() diff --git a/src/alias/azext_alias/tests/test_argument.py b/src/alias/azext_alias/tests/test_argument.py index 3913d73360d..21b25dc1a68 100644 --- a/src/alias/azext_alias/tests/test_argument.py +++ b/src/alias/azext_alias/tests/test_argument.py @@ -55,9 +55,6 @@ def test_normalize_placeholders(self): def test_normalize_placeholders_number(self): self.assertEqual('"{{_0}}" "{{_1}}"', normalize_placeholders('{{ 0 }} {{ 1 }}', inject_quotes=True)) - def test_normalize_placeholders_no_quotes(self): - self.assertEqual('{{_0}} {{_1}}', normalize_placeholders('{{ 0 }} {{ 1 }}')) - def test_normalize_placeholders_number_no_quotes(self): self.assertEqual('{{_0}} {{_1}}', normalize_placeholders('{{ 0 }} {{ 1 }}')) diff --git a/src/alias/azext_alias/tests/test_custom.py b/src/alias/azext_alias/tests/test_custom.py new file mode 100644 index 00000000000..8e431444d36 --- /dev/null +++ b/src/alias/azext_alias/tests/test_custom.py @@ -0,0 +1,117 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=line-too-long,no-self-use,protected-access + +import unittest +from mock import Mock + +from knack.util import CLIError + +import azext_alias +from azext_alias.alias import get_config_parser +from azext_alias.tests._const import TEST_RESERVED_COMMANDS +from azext_alias.custom import ( + create_alias, + list_alias, + remove_alias, +) + + +class AliasCustomCommandTest(unittest.TestCase): + + @classmethod + def setUp(cls): + azext_alias.cached_reserved_commands = TEST_RESERVED_COMMANDS + azext_alias.custom._commit_change = Mock() + + def test_create_alias(self): + create_alias('ac', 'account') + + def test_create_alias_multiple_commands(self): + create_alias('dns', 'network dns') + + def test_create_alias_pos_arg(self): + create_alias('test {{ arg }}', 'account {{ arg }}') + + def test_create_alias_pos_arg_with_addtional_processing(self): + create_alias('test {{ arg }}', 'account {{ arg.replace("https://", "") }}') + + def test_create_alias_pos_arg_with_filter(self): + create_alias('test {{ arg }}', 'account {{ arg | upper }}') + + def test_create_alias_pos_arg_with_filter_and_addtional_processing(self): + create_alias('test {{ arg }}', 'account {{ arg.replace("https://", "") | upper }}') + + def test_create_alias_non_existing_command(self): + with self.assertRaises(CLIError): + create_alias('test', 'non existing command') + + def test_create_alias_empty_alias_name(self): + with self.assertRaises(CLIError): + create_alias('', 'account') + + def test_create_alias_empty_alias_command(self): + with self.assertRaises(CLIError): + create_alias('ac', '') + + def test_create_alias_non_existing_commands_with_pos_arg(self): + with self.assertRaises(CLIError): + create_alias('test {{ arg }}', 'account list {{ arg }}') + + def test_create_alias_inconsistent_pos_arg_name(self): + with self.assertRaises(CLIError): + create_alias('test {{ arg }}', 'account {{ ar }}') + + def test_create_alias_pos_arg_only(self): + with self.assertRaises(CLIError): + create_alias('test {{ arg }}', '{{ arg }}') + + def test_create_alias_inconsistent_number_pos_arg(self): + with self.assertRaises(CLIError): + create_alias('test {{ arg_1 }} {{ arg_2 }}', 'account {{ arg_2 }}') + + def test_create_alias_lvl_error(self): + with self.assertRaises(CLIError): + create_alias('network', 'account list') + + def test_create_alias_lvl_error_with_pos_arg(self): + with self.assertRaises(CLIError): + create_alias('account {{ test }}', 'dns {{ test }}') + + def test_list_alias(self): + mock_alias_table = get_config_parser() + mock_alias_table.add_section('ac') + mock_alias_table.set('ac', 'command', 'account') + azext_alias.custom._get_alias_table = Mock(return_value=mock_alias_table) + self.assertListEqual([{'alias': 'ac', 'command': 'account'}], list_alias()) + + def test_list_alias_key_misspell(self): + mock_alias_table = get_config_parser() + mock_alias_table.add_section('ac') + mock_alias_table.set('ac', 'cmmand', 'account') + azext_alias.custom._get_alias_table = Mock(return_value=mock_alias_table) + self.assertListEqual([], list_alias()) + + def test_list_alias_multiple_alias(self): + mock_alias_table = get_config_parser() + mock_alias_table.add_section('ac') + mock_alias_table.set('ac', 'command', 'account') + mock_alias_table.add_section('dns') + mock_alias_table.set('dns', 'command', 'network dns') + azext_alias.custom._get_alias_table = Mock(return_value=mock_alias_table) + self.assertListEqual([{'alias': 'ac', 'command': 'account'}, {'alias': 'dns', 'command': 'network dns'}], list_alias()) + + def test_remove_alias_remove_non_existing_alias(self): + mock_alias_table = get_config_parser() + mock_alias_table.add_section('ac') + mock_alias_table.set('ac', 'command', 'account') + azext_alias.custom._get_alias_table = Mock(return_value=mock_alias_table) + with self.assertRaises(CLIError): + remove_alias('dns') + + +if __name__ == '__main__': + unittest.main() diff --git a/src/alias/azext_alias/version.py b/src/alias/azext_alias/version.py index 7f76f4a5955..e281432f088 100644 --- a/src/alias/azext_alias/version.py +++ b/src/alias/azext_alias/version.py @@ -3,4 +3,4 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -VERSION = '0.2.0' +VERSION = '0.3.0' diff --git a/src/index.json b/src/index.json index ee936f88efb..de6cddf854a 100644 --- a/src/index.json +++ b/src/index.json @@ -460,9 +460,9 @@ ], "alias": [ { - "filename": "alias-0.2.0-py2.py3-none-any.whl", - "sha256Digest": "3f01195ad2ce32d4332276d63243b064da8df6490509e103c2bb0295a7735425", - "downloadUrl": "https://azurecliprod.blob.core.windows.net/cli-extensions/alias-0.2.0-py2.py3-none-any.whl", + "filename": "alias-0.3.0-py2.py3-none-any.whl", + "sha256Digest": "f40ee29e345d9c6bf78dedec9f1e15232564f255c946587d02274c060740a2e7", + "downloadUrl": "https://azurecliprod.blob.core.windows.net/cli-extensions/alias-0.3.0-py2.py3-none-any.whl", "metadata": { "azext.minCliCoreVersion": "2.0.28", "classifiers": [ From 31c44533612c70fea54211b0471b6ea37392ed2f Mon Sep 17 00:00:00 2001 From: Ernest Wong Date: Wed, 21 Mar 2018 11:52:02 -0700 Subject: [PATCH 2/5] Fix CI build --- src/alias/azext_alias/__init__.py | 3 ++- src/index.json | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/alias/azext_alias/__init__.py b/src/alias/azext_alias/__init__.py index 62da0fda396..9fe2888fee3 100644 --- a/src/alias/azext_alias/__init__.py +++ b/src/alias/azext_alias/__init__.py @@ -74,8 +74,9 @@ def alias_event_handler(_, **kwargs): # Cache the reserved commands for validation later if args[:2] == ['alias', 'create']: + load_cmd_tbl_func = kwargs.get('load_cmd_tbl_func', lambda _: {}) global cached_reserved_commands # pylint: disable=global-statement - cached_reserved_commands = alias_manager.reserved_commands if alias_manager.reserved_commands else kwargs.get('load_cmd_tbl_func')([]).keys() + cached_reserved_commands = alias_manager.reserved_commands if alias_manager.reserved_commands else load_cmd_tbl_func([]).keys() elapsed_time = (timeit.default_timer() - start_time) * 1000 logger.debug(DEBUG_MSG_WITH_TIMING, args, elapsed_time) diff --git a/src/index.json b/src/index.json index de6cddf854a..f9af16ab2d8 100644 --- a/src/index.json +++ b/src/index.json @@ -461,7 +461,7 @@ "alias": [ { "filename": "alias-0.3.0-py2.py3-none-any.whl", - "sha256Digest": "f40ee29e345d9c6bf78dedec9f1e15232564f255c946587d02274c060740a2e7", + "sha256Digest": "231d9c23a2a1864f2f7aad05466be76653289669d6735884ac65f08a0d1696ce", "downloadUrl": "https://azurecliprod.blob.core.windows.net/cli-extensions/alias-0.3.0-py2.py3-none-any.whl", "metadata": { "azext.minCliCoreVersion": "2.0.28", @@ -508,7 +508,7 @@ } ], "summary": "Azure CLI Alias Extension", - "version": "0.2.0" + "version": "0.3.0" } } ], From 2cebfd338c2b4f5a77667164a80142f328bf2ce6 Mon Sep 17 00:00:00 2001 From: Ernest Wong Date: Thu, 22 Mar 2018 10:22:46 -0700 Subject: [PATCH 3/5] Do not translate environment variables for command argument --- src/alias/azext_alias/alias.py | 8 ++++++-- .../azext_alias/tests/test_alias_commands.py | 16 +++++++++++++++- src/index.json | 2 +- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/alias/azext_alias/alias.py b/src/alias/azext_alias/alias.py index e74fe02b40f..c4193da49e3 100644 --- a/src/alias/azext_alias/alias.py +++ b/src/alias/azext_alias/alias.py @@ -212,8 +212,12 @@ def post_transform(self, args): args = args[1:] if args and args[0] == 'az' else args post_transform_commands = [] - for arg in args: - post_transform_commands.append(os.path.expandvars(arg)) + for i, arg in enumerate(args): + # Do not translate environment variables for command argument + if args[:2] == ['alias', 'create'] and i > 0 and args[i - 1] in ['-c', '--command']: + post_transform_commands.append(arg) + else: + post_transform_commands.append(os.path.expandvars(arg)) AliasManager.write_alias_config_hash(self.alias_config_hash) AliasManager.write_collided_alias(self.collided_alias) diff --git a/src/alias/azext_alias/tests/test_alias_commands.py b/src/alias/azext_alias/tests/test_alias_commands.py index 86a5e334a95..458f665ab6b 100644 --- a/src/alias/azext_alias/tests/test_alias_commands.py +++ b/src/alias/azext_alias/tests/test_alias_commands.py @@ -3,7 +3,7 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -# pylint: disable=line-too-long +# pylint: disable=line-too-long,anomalous-backslash-in-string import os import shutil @@ -47,6 +47,20 @@ def test_create_and_list_alias(self): self.check('length(@)', 1) ]) + def test_create_and_list_alias_env_var(self): + self.kwargs.update({ + 'alias_name': 'mkrgrp', + 'alias_command': 'group create -n test --tags owner=\$USER' + }) + self.cmd('az alias create -n \'{alias_name}\' -c \'{alias_command}\'') + self.cmd('az alias list', checks=[ + self.check('[0].alias', '{alias_name}'), + self.check('[0].command', '{alias_command}'), + self.check('length(@)', 1) + ]) + alias_command = self.cmd('az alias list').get_output_in_json()[0]['command'] + assert '\\$USER' in alias_command + def test_create_and_list_alias_with_pos_arg(self): self.kwargs.update({ 'alias_name': 'list-vm {{ resource_group }}', diff --git a/src/index.json b/src/index.json index f9af16ab2d8..f287f0bbc9e 100644 --- a/src/index.json +++ b/src/index.json @@ -461,7 +461,7 @@ "alias": [ { "filename": "alias-0.3.0-py2.py3-none-any.whl", - "sha256Digest": "231d9c23a2a1864f2f7aad05466be76653289669d6735884ac65f08a0d1696ce", + "sha256Digest": "a1015d9fe1cb9071dca9b879c5c551d3628ba90a92d578150f04f3b666e0cd7e", "downloadUrl": "https://azurecliprod.blob.core.windows.net/cli-extensions/alias-0.3.0-py2.py3-none-any.whl", "metadata": { "azext.minCliCoreVersion": "2.0.28", From 11128e4e2ae3cab723b66c8f26fbb5bed8076c12 Mon Sep 17 00:00:00 2001 From: Ernest Wong Date: Mon, 26 Mar 2018 14:05:38 -0700 Subject: [PATCH 4/5] Address PR comments --- src/alias/azext_alias/__init__.py | 19 +++++++++++++------ src/alias/azext_alias/alias.py | 19 +++++++++---------- src/alias/azext_alias/azext_metadata.json | 3 ++- src/alias/azext_alias/custom.py | 10 +++++----- src/alias/azext_alias/tests/test_alias.py | 9 +++++---- src/alias/azext_alias/tests/test_custom.py | 2 +- src/index.json | 3 ++- 7 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/alias/azext_alias/__init__.py b/src/alias/azext_alias/__init__.py index 9fe2888fee3..505ec9bcba9 100644 --- a/src/alias/azext_alias/__init__.py +++ b/src/alias/azext_alias/__init__.py @@ -3,8 +3,6 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -# pylint: disable=line-too-long - import timeit from knack.log import get_logger @@ -22,7 +20,6 @@ from azext_alias import _help # pylint: disable=unused-import logger = get_logger(__name__) -cached_reserved_commands = [] class AliasCommandLoader(AzCommandsLoader): @@ -44,10 +41,21 @@ def load_command_table(self, _): def load_arguments(self, _): with self.argument_context('alias') as c: - c.argument('alias_name', options_list=['--name', '-n'], help='The name of the alias.', completer=get_alias_completer) + c.argument('alias_name', options_list=['--name', '-n'], help='The name of the alias.', + completer=get_alias_completer) c.argument('alias_command', options_list=['--command', '-c'], help='The command that the alias points to.') +class AliasCache(object): # pylint: disable=too-few-public-methods + + reserved_commands = [] + + @staticmethod + def cache_reserved_commands(load_cmd_tbl_func): + if not AliasCache.reserved_commands: + AliasCache.reserved_commands = list(load_cmd_tbl_func([]).keys()) + + @Completer def get_alias_completer(cmd, prefix, namespace, **kwargs): # pylint: disable=unused-argument try: @@ -75,8 +83,7 @@ def alias_event_handler(_, **kwargs): # Cache the reserved commands for validation later if args[:2] == ['alias', 'create']: load_cmd_tbl_func = kwargs.get('load_cmd_tbl_func', lambda _: {}) - global cached_reserved_commands # pylint: disable=global-statement - cached_reserved_commands = alias_manager.reserved_commands if alias_manager.reserved_commands else load_cmd_tbl_func([]).keys() + AliasCache.cache_reserved_commands(load_cmd_tbl_func) elapsed_time = (timeit.default_timer() - start_time) * 1000 logger.debug(DEBUG_MSG_WITH_TIMING, args, elapsed_time) diff --git a/src/alias/azext_alias/alias.py b/src/alias/azext_alias/alias.py index c4193da49e3..e2f0815dca5 100644 --- a/src/alias/azext_alias/alias.py +++ b/src/alias/azext_alias/alias.py @@ -14,6 +14,7 @@ from knack.log import get_logger +import azext_alias from azext_alias import telemetry from azext_alias._const import ( GLOBAL_CONFIG_DIR, @@ -56,7 +57,6 @@ def __init__(self, **kwargs): self.alias_table = get_config_parser() self.kwargs = kwargs self.collided_alias = defaultdict(list) - self.reserved_commands = [] self.alias_config_str = '' self.alias_config_hash = '' self.load_alias_table() @@ -138,22 +138,21 @@ def transform(self, args): if self.detect_alias_config_change(): self.load_full_command_table() self.collided_alias = AliasManager.build_collision_table(self.alias_table.sections(), - self.reserved_commands) + azext_alias.AliasCache.reserved_commands) else: self.load_collided_alias() transformed_commands = [] alias_iter = enumerate(args, 1) - ignore_next_iter = False for alias_index, alias in alias_iter: - # Directly append invalid alias or collided alias - if not alias or alias[0] == '-' or ignore_next_iter or (alias in self.collided_alias and - alias_index in self.collided_alias[alias]): + is_collided_alias = alias in self.collided_alias and alias_index in self.collided_alias[alias] + # Check if the current alias is a named argument + # index - 2 because alias_iter starts counting at index 1 + is_named_arg = alias_index > 1 and args[alias_index - 2].startswith('-') + is_named_arg_flag = alias.startswith('-') + if not alias or is_collided_alias or is_named_arg or is_named_arg_flag: transformed_commands.append(alias) - ignore_next_iter = alias and alias[0] == '-' continue - else: - ignore_next_iter = False full_alias = self.get_full_alias(alias) @@ -198,7 +197,7 @@ def load_full_command_table(self): Perform a full load of the command table to get all the reserved command words. """ load_cmd_tbl_func = self.kwargs.get('load_cmd_tbl_func', lambda _: {}) - self.reserved_commands = list(load_cmd_tbl_func([]).keys()) + azext_alias.AliasCache.reserved_commands = list(load_cmd_tbl_func([]).keys()) telemetry.set_full_command_table_loaded() def post_transform(self, args): diff --git a/src/alias/azext_alias/azext_metadata.json b/src/alias/azext_alias/azext_metadata.json index 059161fdd99..8a5728943a1 100644 --- a/src/alias/azext_alias/azext_metadata.json +++ b/src/alias/azext_alias/azext_metadata.json @@ -1,3 +1,4 @@ { - "azext.minCliCoreVersion": "2.0.28" + "azext.minCliCoreVersion": "2.0.28", + "azext.isPreview": true } \ No newline at end of file diff --git a/src/alias/azext_alias/custom.py b/src/alias/azext_alias/custom.py index 6aee720b181..69b055d60a8 100644 --- a/src/alias/azext_alias/custom.py +++ b/src/alias/azext_alias/custom.py @@ -9,7 +9,7 @@ from knack.util import CLIError -import azext_alias +from azext_alias import AliasCache from azext_alias.argument import get_placeholders from azext_alias._const import ( COLLISION_CHECK_LEVEL_DEPTH, @@ -110,7 +110,7 @@ def _commit_change(alias_table): alias_config_hash = hashlib.sha1(alias_config_file.read().encode('utf-8')).hexdigest() AliasManager.write_alias_config_hash(alias_config_hash) - collided_alias = AliasManager.build_collision_table(alias_table.sections(), azext_alias.cached_reserved_commands) + collided_alias = AliasManager.build_collision_table(alias_table.sections(), AliasCache.reserved_commands) AliasManager.write_collided_alias(collided_alias) @@ -148,7 +148,7 @@ def _validate_alias_command(alias_command): # Extract possible CLI commands and validate command_to_validate = ' '.join(split_command[:boundary_index]).lower() - for command in azext_alias.cached_reserved_commands: + for command in AliasCache.reserved_commands: if re.match(r'([a-z\-]*\s)*{}($|\s)'.format(command_to_validate), command): return @@ -187,13 +187,13 @@ def _validate_alias_command_level(alias, command): alias: The name of the alias. command: The command that the alias points to. """ - alias_collision_table = AliasManager.build_collision_table([alias], azext_alias.cached_reserved_commands) + alias_collision_table = AliasManager.build_collision_table([alias], AliasCache.reserved_commands) # Alias is not a reserved command, so it can point to any command if not alias_collision_table: return - command_collision_table = AliasManager.build_collision_table([command], azext_alias.cached_reserved_commands) + command_collision_table = AliasManager.build_collision_table([command], AliasCache.reserved_commands) alias_collision_levels = alias_collision_table.get(alias.split()[0], []) command_collision_levels = command_collision_table.get(command.split()[0], []) diff --git a/src/alias/azext_alias/tests/test_alias.py b/src/alias/azext_alias/tests/test_alias.py index 83976330bc0..8d67ae143c6 100644 --- a/src/alias/azext_alias/tests/test_alias.py +++ b/src/alias/azext_alias/tests/test_alias.py @@ -14,6 +14,7 @@ from knack.util import CLIError +import azext_alias from azext_alias import alias from azext_alias.tests._const import (DEFAULT_MOCK_ALIAS_STRING, COLLISION_MOCK_ALIAS_STRING, @@ -91,7 +92,7 @@ def test_transform_alias(self, test_case): def test_transform_collided_alias(self, test_case): alias_manager = self.get_alias_manager(COLLISION_MOCK_ALIAS_STRING, TEST_RESERVED_COMMANDS) - alias_manager.collided_alias = alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), alias_manager.reserved_commands) + alias_manager.collided_alias = alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), azext_alias.AliasCache.reserved_commands) self.assertEqual(shlex.split(test_case[1]), alias_manager.transform(shlex.split(test_case[0]))) @@ -152,12 +153,12 @@ def setUp(cls): def test_build_empty_collision_table(self): alias_manager = self.get_alias_manager(DEFAULT_MOCK_ALIAS_STRING, TEST_RESERVED_COMMANDS) - test_case = alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), alias_manager.reserved_commands) + test_case = alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), azext_alias.AliasCache.reserved_commands) self.assertDictEqual(dict(), test_case) def test_build_non_empty_collision_table(self): alias_manager = self.get_alias_manager(COLLISION_MOCK_ALIAS_STRING, TEST_RESERVED_COMMANDS) - test_case = alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), alias_manager.reserved_commands, levels=2) + test_case = alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), azext_alias.AliasCache.reserved_commands, levels=2) self.assertDictEqual({'account': [1, 2], 'dns': [2], 'list-locations': [2]}, test_case) def test_non_parse_error(self): @@ -179,7 +180,7 @@ def test_detect_alias_config_change(self): """ def get_alias_manager(self, mock_alias_str=DEFAULT_MOCK_ALIAS_STRING, reserved_commands=None): alias_manager = MockAliasManager(mock_alias_str=mock_alias_str) - alias_manager.reserved_commands = reserved_commands if reserved_commands else [] + azext_alias.AliasCache.reserved_commands = reserved_commands if reserved_commands else [] return alias_manager def assertAlias(self, value): diff --git a/src/alias/azext_alias/tests/test_custom.py b/src/alias/azext_alias/tests/test_custom.py index 8e431444d36..679c0c0316d 100644 --- a/src/alias/azext_alias/tests/test_custom.py +++ b/src/alias/azext_alias/tests/test_custom.py @@ -24,7 +24,7 @@ class AliasCustomCommandTest(unittest.TestCase): @classmethod def setUp(cls): - azext_alias.cached_reserved_commands = TEST_RESERVED_COMMANDS + azext_alias.AliasCache.reserved_commands = TEST_RESERVED_COMMANDS azext_alias.custom._commit_change = Mock() def test_create_alias(self): diff --git a/src/index.json b/src/index.json index f287f0bbc9e..2d27eb33217 100644 --- a/src/index.json +++ b/src/index.json @@ -461,9 +461,10 @@ "alias": [ { "filename": "alias-0.3.0-py2.py3-none-any.whl", - "sha256Digest": "a1015d9fe1cb9071dca9b879c5c551d3628ba90a92d578150f04f3b666e0cd7e", + "sha256Digest": "128dcda6822b9d65dc25918ac8df346eceb2d52af48f13bcf04a6132c654522b", "downloadUrl": "https://azurecliprod.blob.core.windows.net/cli-extensions/alias-0.3.0-py2.py3-none-any.whl", "metadata": { + "azext.isPreview": true, "azext.minCliCoreVersion": "2.0.28", "classifiers": [ "Development Status :: 4 - Beta", From d147667cc246db098385f0c9c545778ab564c81d Mon Sep 17 00:00:00 2001 From: Ernest Wong Date: Thu, 29 Mar 2018 15:51:32 -0700 Subject: [PATCH 5/5] Address PR comments --- src/alias/azext_alias/__init__.py | 50 +++---- src/alias/azext_alias/_validators.py | 122 +++++++++++++++ src/alias/azext_alias/alias.py | 28 +--- src/alias/azext_alias/custom.py | 139 ++---------------- src/alias/azext_alias/tests/test_alias.py | 17 +-- src/alias/azext_alias/tests/test_custom.py | 48 +----- .../azext_alias/tests/test_validators.py | 60 ++++++++ src/alias/azext_alias/util.py | 54 +++++++ src/alias/setup.py | 4 +- src/index.json | 4 +- 10 files changed, 295 insertions(+), 231 deletions(-) create mode 100644 src/alias/azext_alias/_validators.py create mode 100644 src/alias/azext_alias/tests/test_validators.py create mode 100644 src/alias/azext_alias/util.py diff --git a/src/alias/azext_alias/__init__.py b/src/alias/azext_alias/__init__.py index 505ec9bcba9..1abb3ca84b2 100644 --- a/src/alias/azext_alias/__init__.py +++ b/src/alias/azext_alias/__init__.py @@ -10,54 +10,55 @@ from azure.cli.core import AzCommandsLoader from azure.cli.core.decorators import Completer from azure.cli.core.commands.events import EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE -from azext_alias.alias import ( - GLOBAL_ALIAS_PATH, - AliasManager, - get_config_parser -) +from azext_alias.alias import GLOBAL_ALIAS_PATH, AliasManager +from azext_alias.util import get_config_parser, is_alias_create_command, cache_reserved_commands from azext_alias._const import DEBUG_MSG_WITH_TIMING +from azext_alias._validators import process_alias_create_namespace from azext_alias import telemetry from azext_alias import _help # pylint: disable=unused-import logger = get_logger(__name__) +""" +We don't have access to load_cmd_tbl_func in custom.py (need the entire command table +for alias and command validation when the user invokes alias create). +This cache saves the entire command table globally so custom.py can have access to it. +Alter this cache through cache_reserved_commands(load_cmd_tbl_func) in util.py +""" +cached_reserved_commands = [] -class AliasCommandLoader(AzCommandsLoader): +class AliasExtCommandLoader(AzCommandsLoader): def __init__(self, cli_ctx=None): from azure.cli.core.commands import CliCommandType custom_command_type = CliCommandType(operations_tmpl='azext_alias.custom#{}') - super(AliasCommandLoader, self).__init__(cli_ctx=cli_ctx, - custom_command_type=custom_command_type) + super(AliasExtCommandLoader, self).__init__(cli_ctx=cli_ctx, + custom_command_type=custom_command_type) self.cli_ctx.register_event(EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, alias_event_handler) def load_command_table(self, _): with self.command_group('alias') as g: - g.custom_command('create', 'create_alias') + g.custom_command('create', 'create_alias', validator=process_alias_create_namespace) g.custom_command('list', 'list_alias') g.custom_command('remove', 'remove_alias') return self.command_table def load_arguments(self, _): - with self.argument_context('alias') as c: - c.argument('alias_name', options_list=['--name', '-n'], help='The name of the alias.', - completer=get_alias_completer) + with self.argument_context('alias create') as c: + c.argument('alias_name', options_list=['--name', '-n'], help='The name of the alias.') c.argument('alias_command', options_list=['--command', '-c'], help='The command that the alias points to.') - -class AliasCache(object): # pylint: disable=too-few-public-methods - - reserved_commands = [] - - @staticmethod - def cache_reserved_commands(load_cmd_tbl_func): - if not AliasCache.reserved_commands: - AliasCache.reserved_commands = list(load_cmd_tbl_func([]).keys()) + with self.argument_context('alias remove') as c: + c.argument('alias_name', options_list=['--name', '-n'], help='The name of the alias.', + completer=get_alias_completer) @Completer def get_alias_completer(cmd, prefix, namespace, **kwargs): # pylint: disable=unused-argument + """ + An argument completer for alias name. + """ try: alias_table = get_config_parser() alias_table.read(GLOBAL_ALIAS_PATH) @@ -80,10 +81,9 @@ def alias_event_handler(_, **kwargs): # [:] will keep the reference of the original args args[:] = alias_manager.transform(args) - # Cache the reserved commands for validation later - if args[:2] == ['alias', 'create']: + if is_alias_create_command(args): load_cmd_tbl_func = kwargs.get('load_cmd_tbl_func', lambda _: {}) - AliasCache.cache_reserved_commands(load_cmd_tbl_func) + cache_reserved_commands(load_cmd_tbl_func) elapsed_time = (timeit.default_timer() - start_time) * 1000 logger.debug(DEBUG_MSG_WITH_TIMING, args, elapsed_time) @@ -96,4 +96,4 @@ def alias_event_handler(_, **kwargs): telemetry.conclude() -COMMAND_LOADER_CLS = AliasCommandLoader +COMMAND_LOADER_CLS = AliasExtCommandLoader diff --git a/src/alias/azext_alias/_validators.py b/src/alias/azext_alias/_validators.py new file mode 100644 index 00000000000..f70f8831567 --- /dev/null +++ b/src/alias/azext_alias/_validators.py @@ -0,0 +1,122 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import re +import shlex + +from knack.util import CLIError + +import azext_alias +from azext_alias.argument import get_placeholders +from azext_alias._const import ( + COLLISION_CHECK_LEVEL_DEPTH, + INVALID_ALIAS_COMMAND_ERROR, + EMPTY_ALIAS_ERROR, + INVALID_STARTING_CHAR_ERROR, + INCONSISTENT_ARG_ERROR, + COMMAND_LVL_ERROR +) +from azext_alias.alias import AliasManager + + +def process_alias_create_namespace(namespace): + """ + Validate input arguments when the user invokes 'az alias create'. + + Args: + namespace: argparse namespace object. + """ + _validate_alias_name(namespace.alias_name) + _validate_alias_command(namespace.alias_command) + _validate_alias_command_level(namespace.alias_name, namespace.alias_command) + _validate_pos_args_syntax(namespace.alias_name, namespace.alias_command) + + +def _validate_alias_name(alias_name): + """ + Check if the alias name is valid. + + Args: + alias_name: The name of the alias to validate. + """ + if not alias_name: + raise CLIError(EMPTY_ALIAS_ERROR) + + if not re.match('^[a-zA-Z]', alias_name): + raise CLIError(INVALID_STARTING_CHAR_ERROR.format(alias_name[0])) + + +def _validate_alias_command(alias_command): + """ + Check if the alias command is valid. + + Args: + alias_command: The command to validate. + """ + if not alias_command: + raise CLIError(EMPTY_ALIAS_ERROR) + + # Boundary index is the index at which named argument or positional argument starts + split_command = shlex.split(alias_command) + boundary_index = len(split_command) + for i, subcommand in enumerate(split_command): + if not re.match('^[a-z]', subcommand.lower()) or i > COLLISION_CHECK_LEVEL_DEPTH: + boundary_index = i + break + + # Extract possible CLI commands and validate + command_to_validate = ' '.join(split_command[:boundary_index]).lower() + for command in azext_alias.cached_reserved_commands: + if re.match(r'([a-z\-]*\s)*{}($|\s)'.format(command_to_validate), command): + return + + raise CLIError(INVALID_ALIAS_COMMAND_ERROR.format(command_to_validate if command_to_validate else alias_command)) + + +def _validate_pos_args_syntax(alias_name, alias_command): + """ + Check if the positional argument syntax is valid in alias name and alias command. + + Args: + alias_name: The name of the alias to validate. + alias_command: The command to validate. + """ + pos_args_from_alias = get_placeholders(alias_name) + # Split by '|' to extract positional argument name from Jinja filter (e.g. {{ arg_name | upper }}) + # Split by '.' to extract positional argument name from function call (e.g. {{ arg_name.split()[0] }}) + pos_args_from_command = [x.split('|')[0].split('.')[0].strip() for x in get_placeholders(alias_command)] + + if set(pos_args_from_alias) != set(pos_args_from_command): + arg_diff = set(pos_args_from_alias) ^ set(pos_args_from_command) + raise CLIError(INCONSISTENT_ARG_ERROR.format('' if len(arg_diff) == 1 else 's', + arg_diff, + 'is' if len(arg_diff) == 1 else 'are')) + + +def _validate_alias_command_level(alias, command): + """ + Make sure that if the alias is a reserved command, the command that the alias points to + in the command tree does not conflict in levels. + + e.g. 'dns' -> 'network dns' is valid because dns is a level 2 command and network dns starts at level 1. + However, 'list' -> 'show' is not valid because list and show are both reserved commands at level 2. + + Args: + alias: The name of the alias. + command: The command that the alias points to. + """ + alias_collision_table = AliasManager.build_collision_table([alias], azext_alias.cached_reserved_commands) + + # Alias is not a reserved command, so it can point to any command + if not alias_collision_table: + return + + command_collision_table = AliasManager.build_collision_table([command], azext_alias.cached_reserved_commands) + alias_collision_levels = alias_collision_table.get(alias.split()[0], []) + command_collision_levels = command_collision_table.get(command.split()[0], []) + + # Check if there is a command level conflict + if set(alias_collision_levels) & set(command_collision_levels): + raise CLIError(COMMAND_LVL_ERROR.format(alias, command)) diff --git a/src/alias/azext_alias/alias.py b/src/alias/azext_alias/alias.py index e2f0815dca5..61e4fe8fef8 100644 --- a/src/alias/azext_alias/alias.py +++ b/src/alias/azext_alias/alias.py @@ -5,12 +5,10 @@ import os import re -import sys import json import shlex import hashlib from collections import defaultdict -from six.moves import configparser from knack.log import get_logger @@ -26,10 +24,8 @@ COLLISION_CHECK_LEVEL_DEPTH, POS_ARG_DEBUG_MSG ) -from azext_alias.argument import ( - build_pos_args_table, - render_template -) +from azext_alias.argument import build_pos_args_table, render_template +from azext_alias.util import is_alias_create_command, cache_reserved_commands, get_config_parser GLOBAL_ALIAS_PATH = os.path.join(GLOBAL_CONFIG_DIR, ALIAS_FILE_NAME) @@ -39,18 +35,6 @@ logger = get_logger(__name__) -def get_config_parser(): - """ - Disable configparser's interpolation function and return an instance of config parser. - - Returns: - An instance of config parser with interpolation disabled. - """ - if sys.version_info.major == 3: - return configparser.ConfigParser(interpolation=None) # pylint: disable=unexpected-keyword-arg - return configparser.ConfigParser() - - class AliasManager(object): def __init__(self, **kwargs): @@ -75,7 +59,7 @@ def load_alias_table(self): telemetry.set_number_of_aliases_registered(len(self.alias_table.sections())) except Exception as exception: # pylint: disable=broad-except logger.warning(CONFIG_PARSING_ERROR, AliasManager.process_exception_message(exception)) - self.alias_table = configparser.ConfigParser() + self.alias_table = get_config_parser() telemetry.set_exception(exception) def load_alias_hash(self): @@ -138,7 +122,7 @@ def transform(self, args): if self.detect_alias_config_change(): self.load_full_command_table() self.collided_alias = AliasManager.build_collision_table(self.alias_table.sections(), - azext_alias.AliasCache.reserved_commands) + azext_alias.cached_reserved_commands) else: self.load_collided_alias() @@ -197,7 +181,7 @@ def load_full_command_table(self): Perform a full load of the command table to get all the reserved command words. """ load_cmd_tbl_func = self.kwargs.get('load_cmd_tbl_func', lambda _: {}) - azext_alias.AliasCache.reserved_commands = list(load_cmd_tbl_func([]).keys()) + cache_reserved_commands(load_cmd_tbl_func) telemetry.set_full_command_table_loaded() def post_transform(self, args): @@ -213,7 +197,7 @@ def post_transform(self, args): post_transform_commands = [] for i, arg in enumerate(args): # Do not translate environment variables for command argument - if args[:2] == ['alias', 'create'] and i > 0 and args[i - 1] in ['-c', '--command']: + if is_alias_create_command(args) and i > 0 and args[i - 1] in ['-c', '--command']: post_transform_commands.append(arg) else: post_transform_commands.append(os.path.expandvars(arg)) diff --git a/src/alias/azext_alias/custom.py b/src/alias/azext_alias/custom.py index 69b055d60a8..3bece788d4a 100644 --- a/src/alias/azext_alias/custom.py +++ b/src/alias/azext_alias/custom.py @@ -3,28 +3,14 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -import re -import shlex import hashlib from knack.util import CLIError -from azext_alias import AliasCache -from azext_alias.argument import get_placeholders -from azext_alias._const import ( - COLLISION_CHECK_LEVEL_DEPTH, - ALIAS_NOT_FOUND_ERROR, - INVALID_ALIAS_COMMAND_ERROR, - EMPTY_ALIAS_ERROR, - INVALID_STARTING_CHAR_ERROR, - INCONSISTENT_ARG_ERROR, - COMMAND_LVL_ERROR -) -from azext_alias.alias import ( - GLOBAL_ALIAS_PATH, - get_config_parser, - AliasManager -) +import azext_alias +from azext_alias._const import ALIAS_NOT_FOUND_ERROR +from azext_alias.alias import GLOBAL_ALIAS_PATH, AliasManager +from azext_alias.util import get_alias_table def create_alias(alias_name, alias_command): @@ -36,14 +22,10 @@ def create_alias(alias_name, alias_command): alias_command: The command that the alias points to. """ alias_name, alias_command = alias_name.strip(), alias_command.strip() - _validate_alias_name(alias_name) - _validate_alias_command(alias_command) - _validate_alias_command_level(alias_name, alias_command) - _validate_pos_args_syntax(alias_name, alias_command) - - alias_table = _get_alias_table() + alias_table = get_alias_table() if alias_name not in alias_table.sections(): alias_table.add_section(alias_name) + alias_table.set(alias_name, 'command', alias_command) _commit_change(alias_table) @@ -55,7 +37,7 @@ def list_alias(): Returns: An array of dictionary containing the alias and the command that it points to. """ - alias_table = _get_alias_table() + alias_table = get_alias_table() output = [] for alias in alias_table.sections(): if alias_table.has_option(alias, 'command'): @@ -75,27 +57,13 @@ def remove_alias(alias_name): Args: alias_name: The name of the alias to be removed. """ - alias_table = _get_alias_table() + alias_table = get_alias_table() if alias_name not in alias_table.sections(): raise CLIError(ALIAS_NOT_FOUND_ERROR.format(alias_name)) alias_table.remove_section(alias_name) _commit_change(alias_table) -def _get_alias_table(): - """ - Get the current alias table. - """ - try: - alias_table = get_config_parser() - alias_table.read(GLOBAL_ALIAS_PATH) - return alias_table - except Exception: # pylint: disable=broad-except - # Exception is being handled by self.load_alias_table in alias.py - # so simply raise an empty CLIError to terminate all actions - raise CLIError() - - def _commit_change(alias_table): """ Record changes to the alias table. @@ -110,93 +78,6 @@ def _commit_change(alias_table): alias_config_hash = hashlib.sha1(alias_config_file.read().encode('utf-8')).hexdigest() AliasManager.write_alias_config_hash(alias_config_hash) - collided_alias = AliasManager.build_collision_table(alias_table.sections(), AliasCache.reserved_commands) + collided_alias = AliasManager.build_collision_table(alias_table.sections(), + azext_alias.cached_reserved_commands) AliasManager.write_collided_alias(collided_alias) - - -def _validate_alias_name(alias_name): - """ - Check if the alias name is valid. - - Args: - alias_name: The name of the alias to validate. - """ - if not alias_name: - raise CLIError(EMPTY_ALIAS_ERROR) - - if not re.match('^[a-zA-Z]', alias_name): - raise CLIError(INVALID_STARTING_CHAR_ERROR.format(alias_name[0])) - - -def _validate_alias_command(alias_command): - """ - Check if the alias command is valid. - - Args: - alias_command: The command to validate. - """ - if not alias_command: - raise CLIError(EMPTY_ALIAS_ERROR) - - # Boundary index is the index at which named argument or positional argument starts - split_command = shlex.split(alias_command) - boundary_index = len(split_command) - for i, subcommand in enumerate(split_command): - if not re.match('^[a-z]', subcommand.lower()) or i > COLLISION_CHECK_LEVEL_DEPTH: - boundary_index = i - break - - # Extract possible CLI commands and validate - command_to_validate = ' '.join(split_command[:boundary_index]).lower() - for command in AliasCache.reserved_commands: - if re.match(r'([a-z\-]*\s)*{}($|\s)'.format(command_to_validate), command): - return - - raise CLIError(INVALID_ALIAS_COMMAND_ERROR.format(command_to_validate if command_to_validate else alias_command)) - - -def _validate_pos_args_syntax(alias_name, alias_command): - """ - Check if the positional argument syntax is valid in alias name and alias command. - - Args: - alias_name: The name of the alias to validate. - alias_command: The command to validate. - """ - pos_args_from_alias = get_placeholders(alias_name) - # Split by '|' to extract positional argument name from Jinja filter (e.g. {{ arg_name | upper }}) - # Split by '.' to extract positional argument name from function call (e.g. {{ arg_name.split()[0] }}) - pos_args_from_command = [x.split('|')[0].split('.')[0].strip() for x in get_placeholders(alias_command)] - - if set(pos_args_from_alias) != set(pos_args_from_command): - arg_diff = set(pos_args_from_alias) ^ set(pos_args_from_command) - raise CLIError(INCONSISTENT_ARG_ERROR.format('' if len(arg_diff) == 1 else 's', - arg_diff, - 'is' if len(arg_diff) == 1 else 'are')) - - -def _validate_alias_command_level(alias, command): - """ - Make sure that if the alias is a reserved command, the command that the alias points to - in the command tree does not conflict in levels. - - e.g. 'dns' -> 'network dns' is valid because dns is a level 2 command and network dns starts at level 1. - However, 'list' -> 'show' is not valid because list and show are both reserved commands at level 2. - - Args: - alias: The name of the alias. - command: The command that the alias points to. - """ - alias_collision_table = AliasManager.build_collision_table([alias], AliasCache.reserved_commands) - - # Alias is not a reserved command, so it can point to any command - if not alias_collision_table: - return - - command_collision_table = AliasManager.build_collision_table([command], AliasCache.reserved_commands) - alias_collision_levels = alias_collision_table.get(alias.split()[0], []) - command_collision_levels = command_collision_table.get(command.split()[0], []) - - # Check if there is a command level conflict - if set(alias_collision_levels) & set(command_collision_levels): - raise CLIError(COMMAND_LVL_ERROR.format(alias, command)) diff --git a/src/alias/azext_alias/tests/test_alias.py b/src/alias/azext_alias/tests/test_alias.py index 8d67ae143c6..a511369b2b0 100644 --- a/src/alias/azext_alias/tests/test_alias.py +++ b/src/alias/azext_alias/tests/test_alias.py @@ -15,7 +15,6 @@ from knack.util import CLIError import azext_alias -from azext_alias import alias from azext_alias.tests._const import (DEFAULT_MOCK_ALIAS_STRING, COLLISION_MOCK_ALIAS_STRING, TEST_RESERVED_COMMANDS, @@ -92,7 +91,7 @@ def test_transform_alias(self, test_case): def test_transform_collided_alias(self, test_case): alias_manager = self.get_alias_manager(COLLISION_MOCK_ALIAS_STRING, TEST_RESERVED_COMMANDS) - alias_manager.collided_alias = alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), azext_alias.AliasCache.reserved_commands) + alias_manager.collided_alias = azext_alias.alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), azext_alias.cached_reserved_commands) self.assertEqual(shlex.split(test_case[1]), alias_manager.transform(shlex.split(test_case[0]))) @@ -148,17 +147,17 @@ class TestAlias(unittest.TestCase): @classmethod def setUp(cls): - alias.AliasManager.write_alias_config_hash = Mock() - alias.AliasManager.write_collided_alias = Mock() + azext_alias.alias.AliasManager.write_alias_config_hash = Mock() + azext_alias.alias.AliasManager.write_collided_alias = Mock() def test_build_empty_collision_table(self): alias_manager = self.get_alias_manager(DEFAULT_MOCK_ALIAS_STRING, TEST_RESERVED_COMMANDS) - test_case = alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), azext_alias.AliasCache.reserved_commands) + test_case = azext_alias.alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), azext_alias.cached_reserved_commands) self.assertDictEqual(dict(), test_case) def test_build_non_empty_collision_table(self): alias_manager = self.get_alias_manager(COLLISION_MOCK_ALIAS_STRING, TEST_RESERVED_COMMANDS) - test_case = alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), azext_alias.AliasCache.reserved_commands, levels=2) + test_case = azext_alias.alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), azext_alias.cached_reserved_commands, levels=2) self.assertDictEqual({'account': [1, 2], 'dns': [2], 'list-locations': [2]}, test_case) def test_non_parse_error(self): @@ -167,7 +166,7 @@ def test_non_parse_error(self): def test_detect_alias_config_change(self): alias_manager = self.get_alias_manager() - alias.alias_config_str = DEFAULT_MOCK_ALIAS_STRING + azext_alias.alias.alias_config_str = DEFAULT_MOCK_ALIAS_STRING self.assertFalse(alias_manager.detect_alias_config_change()) alias_manager = self.get_alias_manager() @@ -180,7 +179,7 @@ def test_detect_alias_config_change(self): """ def get_alias_manager(self, mock_alias_str=DEFAULT_MOCK_ALIAS_STRING, reserved_commands=None): alias_manager = MockAliasManager(mock_alias_str=mock_alias_str) - azext_alias.AliasCache.reserved_commands = reserved_commands if reserved_commands else [] + azext_alias.cached_reserved_commands = reserved_commands if reserved_commands else [] return alias_manager def assertAlias(self, value): @@ -193,7 +192,7 @@ def assertPostTransform(self, value, mock_alias_str=DEFAULT_MOCK_ALIAS_STRING): self.assertEqual(shlex.split(value[1]), alias_manager.post_transform(shlex.split(value[0]))) -class MockAliasManager(alias.AliasManager): +class MockAliasManager(azext_alias.alias.AliasManager): def load_alias_table(self): diff --git a/src/alias/azext_alias/tests/test_custom.py b/src/alias/azext_alias/tests/test_custom.py index 679c0c0316d..6976e9cd629 100644 --- a/src/alias/azext_alias/tests/test_custom.py +++ b/src/alias/azext_alias/tests/test_custom.py @@ -11,7 +11,7 @@ from knack.util import CLIError import azext_alias -from azext_alias.alias import get_config_parser +from azext_alias.util import get_config_parser from azext_alias.tests._const import TEST_RESERVED_COMMANDS from azext_alias.custom import ( create_alias, @@ -24,7 +24,7 @@ class AliasCustomCommandTest(unittest.TestCase): @classmethod def setUp(cls): - azext_alias.AliasCache.reserved_commands = TEST_RESERVED_COMMANDS + azext_alias.cached_reserved_commands = TEST_RESERVED_COMMANDS azext_alias.custom._commit_change = Mock() def test_create_alias(self): @@ -45,54 +45,18 @@ def test_create_alias_pos_arg_with_filter(self): def test_create_alias_pos_arg_with_filter_and_addtional_processing(self): create_alias('test {{ arg }}', 'account {{ arg.replace("https://", "") | upper }}') - def test_create_alias_non_existing_command(self): - with self.assertRaises(CLIError): - create_alias('test', 'non existing command') - - def test_create_alias_empty_alias_name(self): - with self.assertRaises(CLIError): - create_alias('', 'account') - - def test_create_alias_empty_alias_command(self): - with self.assertRaises(CLIError): - create_alias('ac', '') - - def test_create_alias_non_existing_commands_with_pos_arg(self): - with self.assertRaises(CLIError): - create_alias('test {{ arg }}', 'account list {{ arg }}') - - def test_create_alias_inconsistent_pos_arg_name(self): - with self.assertRaises(CLIError): - create_alias('test {{ arg }}', 'account {{ ar }}') - - def test_create_alias_pos_arg_only(self): - with self.assertRaises(CLIError): - create_alias('test {{ arg }}', '{{ arg }}') - - def test_create_alias_inconsistent_number_pos_arg(self): - with self.assertRaises(CLIError): - create_alias('test {{ arg_1 }} {{ arg_2 }}', 'account {{ arg_2 }}') - - def test_create_alias_lvl_error(self): - with self.assertRaises(CLIError): - create_alias('network', 'account list') - - def test_create_alias_lvl_error_with_pos_arg(self): - with self.assertRaises(CLIError): - create_alias('account {{ test }}', 'dns {{ test }}') - def test_list_alias(self): mock_alias_table = get_config_parser() mock_alias_table.add_section('ac') mock_alias_table.set('ac', 'command', 'account') - azext_alias.custom._get_alias_table = Mock(return_value=mock_alias_table) + azext_alias.custom.get_alias_table = Mock(return_value=mock_alias_table) self.assertListEqual([{'alias': 'ac', 'command': 'account'}], list_alias()) def test_list_alias_key_misspell(self): mock_alias_table = get_config_parser() mock_alias_table.add_section('ac') mock_alias_table.set('ac', 'cmmand', 'account') - azext_alias.custom._get_alias_table = Mock(return_value=mock_alias_table) + azext_alias.custom.get_alias_table = Mock(return_value=mock_alias_table) self.assertListEqual([], list_alias()) def test_list_alias_multiple_alias(self): @@ -101,14 +65,14 @@ def test_list_alias_multiple_alias(self): mock_alias_table.set('ac', 'command', 'account') mock_alias_table.add_section('dns') mock_alias_table.set('dns', 'command', 'network dns') - azext_alias.custom._get_alias_table = Mock(return_value=mock_alias_table) + azext_alias.custom.get_alias_table = Mock(return_value=mock_alias_table) self.assertListEqual([{'alias': 'ac', 'command': 'account'}, {'alias': 'dns', 'command': 'network dns'}], list_alias()) def test_remove_alias_remove_non_existing_alias(self): mock_alias_table = get_config_parser() mock_alias_table.add_section('ac') mock_alias_table.set('ac', 'command', 'account') - azext_alias.custom._get_alias_table = Mock(return_value=mock_alias_table) + azext_alias.custom.get_alias_table = Mock(return_value=mock_alias_table) with self.assertRaises(CLIError): remove_alias('dns') diff --git a/src/alias/azext_alias/tests/test_validators.py b/src/alias/azext_alias/tests/test_validators.py new file mode 100644 index 00000000000..f13a2bd9ee9 --- /dev/null +++ b/src/alias/azext_alias/tests/test_validators.py @@ -0,0 +1,60 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import unittest + +from knack.util import CLIError + +from azext_alias._validators import process_alias_create_namespace + + +class TestValidators(unittest.TestCase): + + def test_process_alias_create_namespace_non_existing_command(self): + with self.assertRaises(CLIError): + process_alias_create_namespace(MockNamespace('test', 'non existing command')) + + def test_process_alias_create_namespace_empty_alias_name(self): + with self.assertRaises(CLIError): + process_alias_create_namespace(MockNamespace('', 'account')) + + def test_process_alias_create_namespace_empty_alias_command(self): + with self.assertRaises(CLIError): + process_alias_create_namespace(MockNamespace('ac', '')) + + def test_process_alias_create_namespace_non_existing_commands_with_pos_arg(self): + with self.assertRaises(CLIError): + process_alias_create_namespace(MockNamespace('test {{ arg }}', 'account list {{ arg }}')) + + def test_process_alias_create_namespace_inconsistent_pos_arg_name(self): + with self.assertRaises(CLIError): + process_alias_create_namespace(MockNamespace('test {{ arg }}', 'account {{ ar }}')) + + def test_process_alias_create_namespace_pos_arg_only(self): + with self.assertRaises(CLIError): + process_alias_create_namespace(MockNamespace('test {{ arg }}', '{{ arg }}')) + + def test_process_alias_create_namespace_inconsistent_number_pos_arg(self): + with self.assertRaises(CLIError): + process_alias_create_namespace(MockNamespace('test {{ arg_1 }} {{ arg_2 }}', 'account {{ arg_2 }}')) + + def test_process_alias_create_namespace_lvl_error(self): + with self.assertRaises(CLIError): + process_alias_create_namespace(MockNamespace('network', 'account list')) + + def test_process_alias_create_namespace_lvl_error_with_pos_arg(self): + with self.assertRaises(CLIError): + process_alias_create_namespace(MockNamespace('account {{ test }}', 'dns {{ test }}')) + + +class MockNamespace(object): # pylint: disable=too-few-public-methods + + def __init__(self, alias_name, alias_command): + self.alias_name = alias_name + self.alias_command = alias_command + + +if __name__ == '__main__': + unittest.main() diff --git a/src/alias/azext_alias/util.py b/src/alias/azext_alias/util.py new file mode 100644 index 00000000000..19b15731283 --- /dev/null +++ b/src/alias/azext_alias/util.py @@ -0,0 +1,54 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import sys +from six.moves import configparser + +import azext_alias + + +def get_config_parser(): + """ + Disable configparser's interpolation function and return an instance of config parser. + + Returns: + An instance of config parser with interpolation disabled. + """ + if sys.version_info.major == 3: + return configparser.ConfigParser(interpolation=None) # pylint: disable=unexpected-keyword-arg + return configparser.ConfigParser() # pylint: disable=undefined-variable + + +def get_alias_table(): + """ + Get the current alias table. + """ + try: + alias_table = get_config_parser() + alias_table.read(azext_alias.alias.GLOBAL_ALIAS_PATH) + return alias_table + except Exception: # pylint: disable=broad-except + return get_config_parser() + + +def is_alias_create_command(args): + """ + Check if the user is invoking 'az alias create'. + + Returns: + True if the user is invoking 'az alias create'. + """ + return args and args[:2] == ['alias', 'create'] + + +def cache_reserved_commands(load_cmd_tbl_func): + """ + We don't have access to load_cmd_tbl_func in custom.py (need the entire command table + for alias and command validation when the user invokes alias create). + This cache saves the entire command table globally so custom.py can have access to it. + Alter this cache through cache_reserved_commands(load_cmd_tbl_func) in util.py. + """ + if not azext_alias.cached_reserved_commands: + azext_alias.cached_reserved_commands = list(load_cmd_tbl_func([]).keys()) diff --git a/src/alias/setup.py b/src/alias/setup.py index a189ec47cb2..48bc33b47e9 100644 --- a/src/alias/setup.py +++ b/src/alias/setup.py @@ -36,8 +36,8 @@ setup( name='alias', version=VERSION, - description='Azure CLI Alias Extension', - long_description='An Azure CLI extension that provides command alias functionality', + description='Support for command aliases', + long_description='An Azure CLI extension that provides command aliases functionality', license='MIT', author='Ernest Wong', author_email='t-chwong@microsoft.com', diff --git a/src/index.json b/src/index.json index 2d27eb33217..502035cdcea 100644 --- a/src/index.json +++ b/src/index.json @@ -461,7 +461,7 @@ "alias": [ { "filename": "alias-0.3.0-py2.py3-none-any.whl", - "sha256Digest": "128dcda6822b9d65dc25918ac8df346eceb2d52af48f13bcf04a6132c654522b", + "sha256Digest": "d76471db272dec5df441d2b5242f8be8200ae3e47b97f4e4c4cbbd53b2a91ba3", "downloadUrl": "https://azurecliprod.blob.core.windows.net/cli-extensions/alias-0.3.0-py2.py3-none-any.whl", "metadata": { "azext.isPreview": true, @@ -508,7 +508,7 @@ ] } ], - "summary": "Azure CLI Alias Extension", + "summary": "Support for command aliases", "version": "0.3.0" } }