diff --git a/src/alias/azext_alias/__init__.py b/src/alias/azext_alias/__init__.py index 6a6d1baf3de..1abb3ca84b2 100644 --- a/src/alias/azext_alias/__init__.py +++ b/src/alias/azext_alias/__init__.py @@ -8,32 +8,69 @@ 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 +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 AliasExtensionLoader(AzCommandsLoader): +class AliasExtCommandLoader(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(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, _): # pylint:disable=no-self-use - return {} + def load_command_table(self, _): + with self.command_group('alias') as g: + 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, _): # pylint:disable=no-self-use - pass + def load_arguments(self, _): + 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.') + + 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) + 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 +81,10 @@ def alias_event_handler(_, **kwargs): # [:] will keep the reference of the original args args[:] = alias_manager.transform(args) + if is_alias_create_command(args): + load_cmd_tbl_func = kwargs.get('load_cmd_tbl_func', lambda _: {}) + cache_reserved_commands(load_cmd_tbl_func) + elapsed_time = (timeit.default_timer() - start_time) * 1000 logger.debug(DEBUG_MSG_WITH_TIMING, args, elapsed_time) @@ -55,4 +96,4 @@ def alias_event_handler(_, **kwargs): telemetry.conclude() -COMMAND_LOADER_CLS = AliasExtensionLoader +COMMAND_LOADER_CLS = AliasExtCommandLoader 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/_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 7569fd0ad12..61e4fe8fef8 100644 --- a/src/alias/azext_alias/alias.py +++ b/src/alias/azext_alias/alias.py @@ -5,15 +5,14 @@ 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 +import azext_alias from azext_alias import telemetry from azext_alias._const import ( GLOBAL_CONFIG_DIR, @@ -25,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) @@ -38,25 +35,12 @@ 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): 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() @@ -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): @@ -131,23 +115,26 @@ 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(), + azext_alias.cached_reserved_commands) else: self.load_collided_alias() transformed_commands = [] alias_iter = enumerate(args, 1) - 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]): + 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) continue @@ -174,35 +161,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. @@ -223,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 _: {}) - self.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): @@ -237,15 +195,65 @@ 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 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)) - 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 +262,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 +273,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/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 new file mode 100644 index 00000000000..3bece788d4a --- /dev/null +++ b/src/alias/azext_alias/custom.py @@ -0,0 +1,83 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import hashlib + +from knack.util import CLIError + +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): + """ + 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() + 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 _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) diff --git a/src/alias/azext_alias/tests/test_alias.py b/src/alias/azext_alias/tests/test_alias.py index 3af1b93460d..a511369b2b0 100644 --- a/src/alias/azext_alias/tests/test_alias.py +++ b/src/alias/azext_alias/tests/test_alias.py @@ -5,15 +5,16 @@ # 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 -from azext_alias import alias +import azext_alias from azext_alias.tests._const import (DEFAULT_MOCK_ALIAS_STRING, COLLISION_MOCK_ALIAS_STRING, TEST_RESERVED_COMMANDS, @@ -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 = 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]))) @@ -143,14 +145,20 @@ def test(self): class TestAlias(unittest.TestCase): + @classmethod + def setUp(cls): + 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) - self.assertDictEqual(dict(), alias_manager.collided_alias) + 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) - alias_manager.build_collision_table(levels=2) - self.assertDictEqual({'account': [1, 2], 'dns': [2], 'list-locations': [2]}, alias_manager.collided_alias) + 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): alias_manager = self.get_alias_manager() @@ -158,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() @@ -171,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) - alias_manager.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): @@ -184,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): @@ -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..458f665ab6b --- /dev/null +++ b/src/alias/azext_alias/tests/test_alias_commands.py @@ -0,0 +1,152 @@ +# -------------------------------------------------------------------------------------------- +# 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,anomalous-backslash-in-string + +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_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 }}', + '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..6976e9cd629 --- /dev/null +++ b/src/alias/azext_alias/tests/test_custom.py @@ -0,0 +1,81 @@ +# -------------------------------------------------------------------------------------------- +# 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.util 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_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/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/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/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 6f17378fa70..adeae4f5a26 100644 --- a/src/index.json +++ b/src/index.json @@ -460,10 +460,11 @@ ], "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": "d76471db272dec5df441d2b5242f8be8200ae3e47b97f4e4c4cbbd53b2a91ba3", + "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", @@ -507,8 +508,8 @@ ] } ], - "summary": "Azure CLI Alias Extension", - "version": "0.2.0" + "summary": "Support for command aliases", + "version": "0.3.0" } } ],