Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Alias 0.3.0 #105

Merged
merged 6 commits into from
Mar 30, 2018
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 53 additions & 12 deletions src/alias/azext_alias/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,68 @@
from knack.log import get_logger

from azure.cli.core import AzCommandsLoader
from azure.cli.core.commands import CliCommandType
from azure.cli.core.decorators import Completer
from azure.cli.core.commands.events import EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE
from azext_alias.alias import AliasManager
from azext_alias.alias import (
GLOBAL_ALIAS_PATH,
AliasManager,
get_config_parser
)
from azext_alias._const import DEBUG_MSG_WITH_TIMING
from azext_alias import telemetry
from azext_alias import _help # pylint: disable=unused-import

logger = get_logger(__name__)


class AliasExtensionLoader(AzCommandsLoader):
class AliasCommandLoader(AzCommandsLoader):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this name change?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed it to AliasExtCommandLoader.


def __init__(self, cli_ctx=None):
super(AliasExtensionLoader, self).__init__(cli_ctx=cli_ctx,
custom_command_type=CliCommandType())

from azure.cli.core.commands import CliCommandType
custom_command_type = CliCommandType(operations_tmpl='azext_alias.custom#{}')
super(AliasCommandLoader, self).__init__(cli_ctx=cli_ctx,
custom_command_type=custom_command_type)
self.cli_ctx.register_event(EVENT_INVOKER_PRE_CMD_TBL_TRUNCATE, alias_event_handler)

def load_command_table(self, _): # pylint:disable=no-self-use
return {}
def load_command_table(self, _):
with self.command_group('alias') as g:
g.custom_command('create', 'create_alias')
g.custom_command('list', 'list_alias')
g.custom_command('remove', 'remove_alias')

return self.command_table

def load_arguments(self, _):
with self.argument_context('alias') as c:
c.argument('alias_name', options_list=['--name', '-n'], help='The name of the alias.',
completer=get_alias_completer)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need to remove the completer for --name on the 'az alias create' command other I'll get completions for aliases that already exist which is exactly what I wouldn't want.

c.argument('alias_command', options_list=['--command', '-c'], help='The command that the alias points to.')


class AliasCache(object): # pylint: disable=too-few-public-methods

def load_arguments(self, _): # pylint:disable=no-self-use
pass
reserved_commands = []

@staticmethod
def cache_reserved_commands(load_cmd_tbl_func):
if not AliasCache.reserved_commands:
AliasCache.reserved_commands = list(load_cmd_tbl_func([]).keys())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the point in a class that has only one method, no constructor and even that method is static?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AliasCache.reserved_commands could just be a variable like alias_cache_reserved_commands and cache_reserved_commands could be a regular method?



@Completer
def get_alias_completer(cmd, prefix, namespace, **kwargs): # pylint: disable=unused-argument
try:
alias_table = get_config_parser()
alias_table.read(GLOBAL_ALIAS_PATH)
return alias_table.sections()
except Exception: # pylint: disable=broad-except
return []


def alias_event_handler(_, **kwargs):
""" An event handler for alias transformation when EVENT_INVOKER_PRE_TRUNCATE_CMD_TBL event is invoked """
"""
An event handler for alias transformation when EVENT_INVOKER_PRE_TRUNCATE_CMD_TBL event is invoked
"""
try:
telemetry.start()

Expand All @@ -44,6 +80,11 @@ def alias_event_handler(_, **kwargs):
# [:] will keep the reference of the original args
args[:] = alias_manager.transform(args)

# Cache the reserved commands for validation later
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you elaborate this?

Copy link
Author

@chewong chewong Mar 22, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I 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 is a mechanism to save/cache the entire command table globally so custom.py can have access to it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It'd be good to add this comment as an actual comment in the code. No-one is going to come back and look at this comment in this PR to try and understand it.

if args[:2] == ['alias', 'create']:
load_cmd_tbl_func = kwargs.get('load_cmd_tbl_func', lambda _: {})
AliasCache.cache_reserved_commands(load_cmd_tbl_func)

elapsed_time = (timeit.default_timer() - start_time) * 1000
logger.debug(DEBUG_MSG_WITH_TIMING, args, elapsed_time)

Expand All @@ -55,4 +96,4 @@ def alias_event_handler(_, **kwargs):
telemetry.conclude()


COMMAND_LOADER_CLS = AliasExtensionLoader
COMMAND_LOADER_CLS = AliasCommandLoader
10 changes: 8 additions & 2 deletions src/alias/azext_alias/_const.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,20 @@
ALIAS_FILE_NAME = 'alias'
ALIAS_HASH_FILE_NAME = 'alias.sha1'
COLLIDED_ALIAS_FILE_NAME = 'collided_alias'
COLLISION_CHECK_LEVEL_DEPTH = 4
COLLISION_CHECK_LEVEL_DEPTH = 5

INSUFFICIENT_POS_ARG_ERROR = 'alias: "{}" takes exactly {} positional argument{} ({} given)'
CONFIG_PARSING_ERROR = 'alias: Error parsing the configuration file - {}. Please fix the problem manually.'
CONFIG_PARSING_ERROR = 'alias: Error parsing the configuration file - %s. Please fix the problem manually.'
DEBUG_MSG = 'Alias Manager: Transforming "%s" to "%s"'
DEBUG_MSG_WITH_TIMING = 'Alias Manager: Transformed args to %s in %.3fms'
POS_ARG_DEBUG_MSG = 'Alias Manager: Transforming "%s" to "%s", with the following positional arguments: %s'
DUPLICATED_PLACEHOLDER_ERROR = 'alias: Duplicated placeholders found when transforming "{}"'
RENDER_TEMPLATE_ERROR = 'alias: Encounted the following error when injecting positional arguments to "{}" - {}'
PLACEHOLDER_EVAL_ERROR = 'alias: Encounted the following error when evaluating "{}" - {}'
PLACEHOLDER_BRACKETS_ERROR = 'alias: Brackets in "{}" are not enclosed properly'
ALIAS_NOT_FOUND_ERROR = 'alias: "{}" alias not found'
INVALID_ALIAS_COMMAND_ERROR = 'alias: Invalid Azure CLI command "{}"'
EMPTY_ALIAS_ERROR = 'alias: Empty alias name or command is invalid'
INVALID_STARTING_CHAR_ERROR = 'alias: Alias name should not start with "{}"'
INCONSISTENT_ARG_ERROR = 'alias: Positional argument{} {} {} not in both alias name and alias command'
COMMAND_LVL_ERROR = 'alias: "{}" is a reserved command and cannot be used to represent "{}"'
48 changes: 48 additions & 0 deletions src/alias/azext_alias/_help.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# --------------------------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

from knack.help_files import helps # pylint: disable=unused-import


helps['alias'] = """
type: group
short-summary: Manage Azure CLI Aliases.
"""


helps['alias create'] = """
type: command
short-summary: Create an alias.
examples:
- name: Create a simple alias.
text: >
az alias create --name rg --command group\n
az alias create --name ls --command list
- name: Create a complex alias.
text: >
az alias create --name list-vm --command 'vm list --resource-group myResourceGroup'

- name: Create an alias with positional arguments.
text: >
az alias create --name 'list-vm {{ resource_group }}' --command 'vm list --resource-group {{ resource_group }}'

- name: Create an alias with positional arguments and additional string processing.
text: >
az alias create --name 'storage-ls {{ url }}' --command 'storage blob list \n
--account-name {{ url.replace("https://", "").split(".")[0] }}\n
--container-name {{ url.replace("https://", "").split("/")[1] }}'
"""


helps['alias list'] = """
type: command
short-summary: List the registered aliases.
"""


helps['alias remove'] = """
type: command
short-summary: Remove an alias.
"""
127 changes: 70 additions & 57 deletions src/alias/azext_alias/alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from knack.log import get_logger

import azext_alias
from azext_alias import telemetry
from azext_alias._const import (
GLOBAL_CONFIG_DIR,
Expand Down Expand Up @@ -56,7 +57,6 @@ def __init__(self, **kwargs):
self.alias_table = get_config_parser()
self.kwargs = kwargs
self.collided_alias = defaultdict(list)
self.reserved_commands = []
self.alias_config_str = ''
self.alias_config_hash = ''
self.load_alias_table()
Expand Down Expand Up @@ -131,23 +131,26 @@ def transform(self, args):
"""
if self.parse_error():
# Write an empty hash so next run will check the config file against the entire command table again
self.write_alias_config_hash(True)
AliasManager.write_alias_config_hash(empty_hash=True)
return args

# Only load the entire command table if it detects changes in the alias config
if self.detect_alias_config_change():
self.load_full_command_table()
self.build_collision_table()
self.collided_alias = AliasManager.build_collision_table(self.alias_table.sections(),
azext_alias.AliasCache.reserved_commands)
else:
self.load_collided_alias()

transformed_commands = []
alias_iter = enumerate(args, 1)

for alias_index, alias in alias_iter:
# Directly append invalid alias or collided alias
if not alias or alias[0] == '-' or (alias in self.collided_alias and
alias_index in self.collided_alias[alias]):
is_collided_alias = alias in self.collided_alias and alias_index in self.collided_alias[alias]
# Check if the current alias is a named argument
# index - 2 because alias_iter starts counting at index 1
is_named_arg = alias_index > 1 and args[alias_index - 2].startswith('-')
is_named_arg_flag = alias.startswith('-')
if not alias or is_collided_alias or is_named_arg or is_named_arg_flag:
transformed_commands.append(alias)
continue

Expand All @@ -174,35 +177,6 @@ def transform(self, args):

return self.post_transform(transformed_commands)

def build_collision_table(self, levels=COLLISION_CHECK_LEVEL_DEPTH):
"""
Build the collision table according to the alias configuration file against the entire command table.

self.collided_alias is structured as:
{
'collided_alias': [the command level at which collision happens]
}
For example:
{
'account': [1, 2]
}
This means that 'account' is a reserved command in level 1 and level 2 of the command tree because
(az account ...) and (az storage account ...)
lvl 1 lvl 2

Args:
levels: the amount of levels we tranverse through the command table tree.
"""
for alias in self.alias_table.sections():
# Only care about the first word in the alias because alias
# cannot have spaces (unless they have positional arguments)
word = alias.split()[0]
for level in range(1, levels + 1):
collision_regex = r'^{}{}($|\s)'.format(r'([a-z\-]*\s)' * (level - 1), word.lower())
if list(filter(re.compile(collision_regex).match, self.reserved_commands)):
self.collided_alias[word].append(level)
telemetry.set_collided_aliases(list(self.collided_alias.keys()))

def get_full_alias(self, query):
"""
Get the full alias given a search query.
Expand All @@ -223,7 +197,7 @@ def load_full_command_table(self):
Perform a full load of the command table to get all the reserved command words.
"""
load_cmd_tbl_func = self.kwargs.get('load_cmd_tbl_func', lambda _: {})
self.reserved_commands = list(load_cmd_tbl_func([]).keys())
azext_alias.AliasCache.reserved_commands = list(load_cmd_tbl_func([]).keys())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is similar to that in alias_event_handler.
Should have a util method to update azext_alias.AliasCache.reserved_commands so you don't have to duplicate this.

telemetry.set_full_command_table_loaded()

def post_transform(self, args):
Expand All @@ -237,15 +211,65 @@ def post_transform(self, args):
args = args[1:] if args and args[0] == 'az' else args

post_transform_commands = []
for arg in args:
post_transform_commands.append(os.path.expandvars(arg))
for i, arg in enumerate(args):
# Do not translate environment variables for command argument
if args[:2] == ['alias', 'create'] and i > 0 and args[i - 1] in ['-c', '--command']:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider a util method called is_alias_create_command(args) since I've seen this used in a few places. I believe that's what args[:2] == ['alias', 'create'] is for?

post_transform_commands.append(arg)
else:
post_transform_commands.append(os.path.expandvars(arg))

self.write_alias_config_hash()
self.write_collided_alias()
AliasManager.write_alias_config_hash(self.alias_config_hash)
AliasManager.write_collided_alias(self.collided_alias)

return post_transform_commands

def write_alias_config_hash(self, empty_hash=False):
def parse_error(self):
"""
Check if there is a configuration parsing error.

A parsing error has occurred if there are strings inside the alias config file
but there is no alias loaded in self.alias_table.

Returns:
True if there is an error parsing the alias configuration file. Otherwises, false.
"""
return not self.alias_table.sections() and self.alias_config_str

@staticmethod
def build_collision_table(aliases, reserved_commands, levels=COLLISION_CHECK_LEVEL_DEPTH):
"""
Build the collision table according to the alias configuration file against the entire command table.

self.collided_alias is structured as:
{
'collided_alias': [the command level at which collision happens]
}
For example:
{
'account': [1, 2]
}
This means that 'account' is a reserved command in level 1 and level 2 of the command tree because
(az account ...) and (az storage account ...)
lvl 1 lvl 2

Args:
levels: the amount of levels we tranverse through the command table tree.
"""
collided_alias = defaultdict(list)
for alias in aliases:
# Only care about the first word in the alias because alias
# cannot have spaces (unless they have positional arguments)
word = alias.split()[0]
for level in range(1, levels + 1):
collision_regex = r'^{}{}($|\s)'.format(r'([a-z\-]*\s)' * (level - 1), word.lower())
if list(filter(re.compile(collision_regex).match, reserved_commands)):
collided_alias[word].append(level)

telemetry.set_collided_aliases(list(collided_alias.keys()))
return collided_alias

@staticmethod
def write_alias_config_hash(alias_config_hash='', empty_hash=False):
"""
Write self.alias_config_hash to the alias hash file.

Expand All @@ -254,29 +278,18 @@ def write_alias_config_hash(self, empty_hash=False):
means that we have to perform a full load of the command table in the next run.
"""
with open(GLOBAL_ALIAS_HASH_PATH, 'w') as alias_config_hash_file:
alias_config_hash_file.write('' if empty_hash else self.alias_config_hash)
alias_config_hash_file.write('' if empty_hash else alias_config_hash)

def write_collided_alias(self):
@staticmethod
def write_collided_alias(collided_alias_dict):
"""
Write the collided aliases string into the collided alias file.
"""
# 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(self.collided_alias))

def parse_error(self):
"""
Check if there is a configuration parsing error.

A parsing error has occurred if there are strings inside the alias config file
but there is no alias loaded in self.alias_table.

Returns:
True if there is an error parsing the alias configuration file. Otherwises, false.
"""
return not self.alias_table.sections() and self.alias_config_str
collided_alias_file.write(json.dumps(collided_alias_dict))

@staticmethod
def process_exception_message(exception):
Expand Down
3 changes: 2 additions & 1 deletion src/alias/azext_alias/azext_metadata.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"azext.minCliCoreVersion": "2.0.28"
"azext.minCliCoreVersion": "2.0.28",
"azext.isPreview": true
}
Loading