Skip to content

Commit

Permalink
Add features from Azure CLI. (#124)
Browse files Browse the repository at this point in the history
  • Loading branch information
tjprescott authored Nov 7, 2018
1 parent 06504bd commit e528db1
Show file tree
Hide file tree
Showing 10 changed files with 216 additions and 76 deletions.
79 changes: 77 additions & 2 deletions knack/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

from .deprecation import Deprecated
from .log import get_logger
from .util import CLIError


logger = get_logger(__name__)

Expand Down Expand Up @@ -125,7 +127,7 @@ def get_cli_argument(self, command, name):


class ArgumentsContext(object):
def __init__(self, command_loader, command_scope):
def __init__(self, command_loader, command_scope, **kwargs): # pylint: disable=unused-argument
""" Context manager to register arguments
:param command_loader: The command loader that arguments should be registered into
Expand All @@ -136,12 +138,26 @@ def __init__(self, command_loader, command_scope):
"""
self.command_loader = command_loader
self.command_scope = command_scope
self.is_stale = False

def __enter__(self):
return self

def __exit__(self, exc_type, exc_val, exc_tb):
pass
self.is_stale = True

def _applicable(self):
if self.command_loader.skip_applicability:
return True
return self.command_loader.cli_ctx.invocation.data['command_string'].startswith(self.command_scope)

def _check_stale(self):
if self.is_stale:
message = "command authoring error: argument context '{}' is stale! " \
"Check that the subsequent block for has a corresponding `as` " \
"statement.".format(self.command_scope)
logger.error(message)
raise CLIError(message)

def _get_parent_class(self, **kwargs):
# wrap any existing action
Expand Down Expand Up @@ -207,6 +223,7 @@ def __call__(self, parser, namespace, values, option_string=None):
action = _handle_option_deprecation(deprecated_opts)
return action

# pylint: disable=inconsistent-return-statements
def deprecate(self, **kwargs):

def _get_deprecated_arg_message(self):
Expand All @@ -220,6 +237,10 @@ def _get_deprecated_arg_message(self):
msg += " Use '{}' instead.".format(self.redirect)
return msg

self._check_stale()
if not self._applicable():
return

target = kwargs.get('target', '')
kwargs['object_type'] = 'option' if target.startswith('-') else 'argument'
kwargs['message_func'] = _get_deprecated_arg_message
Expand All @@ -235,6 +256,10 @@ def argument(self, argument_dest, arg_type=None, **kwargs):
:param kwargs: Possible values: `options_list`, `validator`, `completer`, `nargs`, `action`, `const`, `default`,
`type`, `choices`, `required`, `help`, `metavar`. See /docs/arguments.md.
"""
self._check_stale()
if not self._applicable():
return

deprecate_action = self._handle_deprecations(argument_dest, **kwargs)
if deprecate_action:
kwargs['action'] = deprecate_action
Expand All @@ -243,12 +268,53 @@ def argument(self, argument_dest, arg_type=None, **kwargs):
arg_type,
**kwargs)

def positional(self, argument_dest, arg_type=None, **kwargs):
""" Register a positional argument for the given command scope using a knack.arguments.CLIArgumentType
:param argument_dest: The destination argument to add this argument type to
:type argument_dest: str
:param arg_type: Predefined CLIArgumentType definition to register, as modified by any provided kwargs.
:type arg_type: knack.arguments.CLIArgumentType
:param kwargs: Possible values: `validator`, `completer`, `nargs`, `action`, `const`, `default`,
`type`, `choices`, `required`, `help`, `metavar`. See /docs/arguments.md.
"""
self._check_stale()
if not self._applicable():
return

if self.command_scope not in self.command_loader.command_table:
raise ValueError("command authoring error: positional argument '{}' cannot be registered to a group-level "
"scope '{}'. It must be registered to a specific command.".format(
argument_dest, self.command_scope))

# Before adding the new positional arg, ensure that there are no existing positional arguments
# registered for this command.
command_args = self.command_loader.argument_registry.arguments[self.command_scope]
positional_args = {k: v for k, v in command_args.items() if v.settings.get('options_list') == []}
if positional_args and argument_dest not in positional_args:
raise CLIError("command authoring error: commands may have, at most, one positional argument. '{}' already "
"has positional argument: {}.".format(self.command_scope, ' '.join(positional_args.keys())))

deprecate_action = self._handle_deprecations(argument_dest, **kwargs)
if deprecate_action:
kwargs['action'] = deprecate_action

kwargs['options_list'] = []
self.command_loader.argument_registry.register_cli_argument(self.command_scope,
argument_dest,
arg_type,
**kwargs)

def ignore(self, argument_dest, **kwargs):
""" Register an argument with type knack.arguments.ignore_type (hidden/ignored)
:param argument_dest: The destination argument to apply the ignore type to
:type argument_dest: str
"""
self._check_stale()
if not self._applicable():
return

dest_option = ['--__{}'.format(argument_dest.upper())]
self.argument(argument_dest, arg_type=ignore_type, options_list=dest_option, **kwargs)

Expand All @@ -261,6 +327,15 @@ def extra(self, argument_dest, **kwargs):
:param kwargs: Possible values: `options_list`, `validator`, `completer`, `nargs`, `action`, `const`, `default`,
`type`, `choices`, `required`, `help`, `metavar`. See /docs/arguments.md.
"""
self._check_stale()
if not self._applicable():
return

if self.command_scope not in self.command_loader.command_table:
raise ValueError("command authoring error: extra argument '{}' cannot be registered to a group-level "
"scope '{}'. It must be registered to a specific command.".format(
argument_dest, self.command_scope))

deprecate_action = self._handle_deprecations(argument_dest, **kwargs)
if deprecate_action:
kwargs['action'] = deprecate_action
Expand Down
17 changes: 13 additions & 4 deletions knack/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# --------------------------------------------------------------------------------------------

from __future__ import print_function
import os
import sys
from collections import defaultdict

Expand Down Expand Up @@ -79,12 +80,16 @@ def __init__(self,
self.invocation = None
self._event_handlers = defaultdict(lambda: [])
# Data that's typically backed to persistent storage
self.config = config_cls(config_dir=config_dir, config_env_var_prefix=config_env_var_prefix)
self.config = config_cls(
config_dir=config_dir or os.path.join('~', '.{}'.format(cli_name)),
config_env_var_prefix=config_env_var_prefix or cli_name.upper()
)
# In memory collection of key-value data for this current cli. This persists between invocations.
self.data = defaultdict(lambda: None)
self.completion = completion_cls(cli_ctx=self)
self.logging = logging_cls(self.name, cli_ctx=self)
self.output = self.output_cls(cli_ctx=self)
self.result = None
self.query = query_cls(cli_ctx=self)

@staticmethod
Expand Down Expand Up @@ -176,6 +181,8 @@ def invoke(self, args, initial_invocation_data=None, out_file=None):
:return: The exit code of the invocation
:rtype: int
"""
from .util import CommandResultItem

if not isinstance(args, (list, tuple)):
raise TypeError('args should be a list or tuple.')
try:
Expand All @@ -195,16 +202,18 @@ def invoke(self, args, initial_invocation_data=None, out_file=None):
help_cls=self.help_cls,
initial_data=initial_invocation_data)
cmd_result = self.invocation.execute(args)
self.result = cmd_result
output_type = self.invocation.data['output']
if cmd_result and cmd_result.result is not None:
formatter = self.output.get_formatter(output_type)
self.output.out(cmd_result, formatter=formatter, out_file=out_file)
self.raise_event(EVENT_CLI_POST_EXECUTE)
exit_code = 0
except KeyboardInterrupt:
except KeyboardInterrupt as ex:
self.result = CommandResultItem(None, exit_code=1, error=ex)
exit_code = 1
except Exception as ex: # pylint: disable=broad-except
exit_code = self.exception_handler(ex)
self.result = CommandResultItem(None, exit_code=exit_code, error=ex)
finally:
pass
return exit_code
return self.result.exit_code
9 changes: 9 additions & 0 deletions knack/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ def _user_confirmed(confirmation, command_args):
return False


# pylint: disable=too-many-instance-attributes
class CLICommandsLoader(object):

def __init__(self, cli_ctx=None, command_cls=CLICommand, excluded_command_handler_args=None):
Expand All @@ -135,6 +136,7 @@ def __init__(self, cli_ctx=None, command_cls=CLICommand, excluded_command_handle
raise CtxTypeError(cli_ctx)
self.cli_ctx = cli_ctx
self.command_cls = command_cls
self.skip_applicability = False
self.excluded_command_handler_args = excluded_command_handler_args
# A command table is a dictionary of name -> CLICommand instances
self.command_table = dict()
Expand Down Expand Up @@ -172,11 +174,18 @@ def load_arguments(self, command):
:param command: The command to load arguments for
:type command: str
"""
from knack.arguments import ArgumentsContext

self.cli_ctx.raise_event(EVENT_CMDLOADER_LOAD_ARGUMENTS, cmd_tbl=self.command_table, command=command)
try:
self.command_table[command].load_arguments()
except KeyError:
return

# ensure global 'cmd' is ignored
with ArgumentsContext(self, '') as c:
c.ignore('cmd')

self._apply_parameter_info(command, self.command_table[command])

def _apply_parameter_info(self, command_name, command):
Expand Down
32 changes: 23 additions & 9 deletions knack/help.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,7 @@ def _load_from_data(self, data):
return

if 'type' in data:
builtin_types = ['command', 'group']
help_type = data['type']
if help_type in builtin_types and help_type != self.type:
raise TypeError("Help type for '{}' should be '{}', not '{}'".format(self.command, self.type,
help_type))
else:
self.type = help_type
self.type = data['type']

if 'short-summary' in data:
self.short_summary = data['short-summary']
Expand Down Expand Up @@ -243,7 +237,20 @@ def __init__(self, help_ctx, delimiters, parser):
self.parameters = []

for action in [a for a in parser._actions if a.help != argparse.SUPPRESS]: # pylint: disable=protected-access
self._add_parameter_help(action)
if action.option_strings:
self._add_parameter_help(action)
else:
# use metavar for positional parameters
param_kwargs = {
'name_source': [action.metavar or action.dest],
'deprecate_info': getattr(action, 'deprecate_info', None),
'description': action.help,
'choices': action.choices,
'required': False,
'default': None,
'group_name': 'Positional'
}
self.parameters.append(HelpParameter(**param_kwargs))

help_param = next(p for p in self.parameters if p.name == '--help -h')
help_param.group_name = 'Global Arguments'
Expand Down Expand Up @@ -339,7 +346,6 @@ def __init__(self, _data):

class CLIHelp(object):

# pylint: disable=no-self-use
def _print_header(self, cli_name, help_file):
indent = 0
_print_indent('')
Expand Down Expand Up @@ -586,6 +592,14 @@ def _print_detailed_help(self, cli_name, help_file):
if help_file.long_summary or getattr(help_file, 'deprecate_info', None):
_print_indent('')

# fix incorrect groupings instead of crashing
if help_file.type == 'command' and not isinstance(help_file, CommandHelpFile):
help_file.type = 'group'
logger.info("'%s' is labeled a command but is actually a group!", help_file.delimiters)
elif help_file.type == 'group' and not isinstance(help_file, GroupHelpFile):
help_file.type = 'command'
logger.info("'%s' is labeled a group but is actually a command!", help_file.delimiters)

if help_file.type == 'command':
_print_indent('Arguments')
self._print_arguments(help_file)
Expand Down
39 changes: 27 additions & 12 deletions knack/invocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,28 @@ def _filter_params(self, args): # pylint: disable=no-self-use
params.pop('command', None)
return params

def _rudimentary_get_command(self, args): # pylint: disable=no-self-use
def _rudimentary_get_command(self, args):
""" Rudimentary parsing to get the command """
nouns = []
for i, current in enumerate(args):
try:
if current[0] == '-':
break
except IndexError:
pass
args[i] = current.lower()
nouns.append(args[i])
command_names = self.commands_loader.command_table.keys()
for arg in args:
if arg and arg[0] != '-':
nouns.append(arg)
else:
break

def _find_args(args):
search = ' '.join(args).lower()
return next((x for x in command_names if x.startswith(search)), False)

# since the command name may be immediately followed by a positional arg, strip those off
while nouns and not _find_args(nouns):
del nouns[-1]

# ensure the command string is case-insensitive
for i in range(len(nouns)):
args[i] = args[i].lower()

return ' '.join(nouns)

def _validate_cmd_level(self, ns, cmd_validator): # pylint: disable=no-self-use
Expand Down Expand Up @@ -119,6 +130,7 @@ def execute(self, args):
self.cli_ctx.raise_event(EVENT_INVOKER_PRE_CMD_TBL_CREATE, args=args)
cmd_tbl = self.commands_loader.load_command_table(args)
command = self._rudimentary_get_command(args)
self.cli_ctx.invocation.data['command_string'] = command
self.commands_loader.load_arguments(command)

self.cli_ctx.raise_event(EVENT_INVOKER_POST_CMD_TBL_CREATE, cmd_tbl=cmd_tbl)
Expand All @@ -143,15 +155,17 @@ def execute(self, args):

self._validation(parsed_args)

# save the command name (leaf in the tree)
self.data['command'] = parsed_args.command

params = self._filter_params(parsed_args)

cmd = parsed_args.func
if hasattr(parsed_args, 'cmd'):
parsed_args.cmd = cmd
deprecations = getattr(parsed_args, '_argument_deprecations', [])
if cmd.deprecate_info:
deprecations.append(cmd.deprecate_info)

params = self._filter_params(parsed_args)

# search for implicit deprecation
path_comps = cmd.name.split()[:-1]
implicit_deprecate_info = None
Expand Down Expand Up @@ -179,5 +193,6 @@ def execute(self, args):
self.cli_ctx.raise_event(EVENT_INVOKER_FILTER_RESULT, event_data=event_data)

return CommandResultItem(event_data['result'],
exit_code=0,
table_transformer=cmd_tbl[parsed_args.command].table_transformer,
is_query_active=self.data['query_active'])
Loading

0 comments on commit e528db1

Please sign in to comment.