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 2 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
58 changes: 46 additions & 12 deletions src/alias/azext_alias/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,37 +3,65 @@
# Licensed under the MIT License. See License.txt in the project root for license information.
# --------------------------------------------------------------------------------------------

# pylint: disable=line-too-long
Copy link

Choose a reason for hiding this comment

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

I don't think we need this. You can break the line to fix in 120 char.


import timeit

from knack.log import get_logger

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

logger = get_logger(__name__)
cached_reserved_commands = []


class AliasExtensionLoader(AzCommandsLoader):
class AliasCommandLoader(AzCommandsLoader):
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')

def load_arguments(self, _): # pylint:disable=no-self-use
pass
return self.command_table

def load_arguments(self, _):
with self.argument_context('alias') as c:
c.argument('alias_name', options_list=['--name', '-n'], help='The name of the alias.', completer=get_alias_completer)
c.argument('alias_command', options_list=['--command', '-c'], help='The command that the alias points to.')


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


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

Expand All @@ -44,6 +72,12 @@ 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 _: {})
global cached_reserved_commands # pylint: disable=global-statement
Copy link

Choose a reason for hiding this comment

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

Is there a way to avoid global?

Copy link
Author

Choose a reason for hiding this comment

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

Added a class called AliasCache to store the reserved commands.

cached_reserved_commands = alias_manager.reserved_commands if alias_manager.reserved_commands else load_cmd_tbl_func([]).keys()

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

Expand All @@ -55,4 +89,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.
"""
114 changes: 62 additions & 52 deletions src/alias/azext_alias/alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,25 +131,29 @@ def transform(self, args):
"""
if self.parse_error():
# Write an empty hash so next run will check the config file against the entire command table again
self.write_alias_config_hash(True)
AliasManager.write_alias_config_hash(empty_hash=True)
return args

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

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

ignore_next_iter = False
for alias_index, alias in alias_iter:
# Directly append invalid alias or collided alias
if not alias or alias[0] == '-' or (alias in self.collided_alias and
alias_index in self.collided_alias[alias]):
if not alias or alias[0] == '-' or ignore_next_iter or (alias in self.collided_alias and
alias_index in self.collided_alias[alias]):
transformed_commands.append(alias)
ignore_next_iter = alias and alias[0] == '-'
continue
else:
ignore_next_iter = False
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 the logic here in the comments?

Copy link
Author

Choose a reason for hiding this comment

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

The logic here is to not transform named arguments. For example, if you have an alias ls pointing to list, running az vm create -g xxx -n ls should not yield az vm create -g xxx -n list,
I think I can make it less ugly and more readable.


full_alias = self.get_full_alias(alias)

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

return self.post_transform(transformed_commands)

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

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

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

def get_full_alias(self, query):
"""
Get the full alias given a search query.
Expand Down Expand Up @@ -240,12 +215,58 @@ def post_transform(self, args):
for arg in args:
post_transform_commands.append(os.path.expandvars(arg))

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

return post_transform_commands

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

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

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

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

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

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

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

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

Expand All @@ -254,29 +275,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
Loading