#
# Copyright (c) 2015, ParaTools, Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
# (1) Redistributions of source code must retain the above copyright notice,
# this list of conditions and the following disclaimer.
# (2) Redistributions in binary form must reproduce the above copyright notice,
# this list of conditions and the following disclaimer in the documentation
# and/or other materials provided with the distribution.
# (3) Neither the name of ParaTools, Inc. nor the names of its contributors may
# be used to endorse or promote products derived from this software without
# specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
#
"""TAU Commander command line interface (CLI).
The TAU Commander CLI is composed of a single top-level command that invokes
subcommands, much like `git`_. For example, the command line
``tau project create my_new_project`` invokes the `create` subcommand of the
`project` subcommand with the arguments `my_new_project`.
Every package in :py:mod:`taucmdr.cli.commands` is a TAU Commander subcommand. Modules
in the package are that subcommand's subcommands. This can be nested as deep as
you like. Subcommand modules must have a COMMAND member which is an instance of
a subclass of :any:`AbstractCommand`.
.. _git: https://git-scm.com/
"""
import os
import pkgutil
import sys
from types import ModuleType
from taucmdr import TAUCMDR_SCRIPT, EXIT_FAILURE
from taucmdr import logger, util
from taucmdr.error import ConfigurationError, InternalError
LOGGER = logger.get_logger(__name__)
SCRIPT_COMMAND = os.path.basename(TAUCMDR_SCRIPT)
COMMANDS_PACKAGE_NAME = __name__ + '.commands'
USAGE_FORMAT = "console"
"""Specify usage formatting:
console: colorized and formatted to fit current console dimensions.
markdown: plain text markdown.
"""
_COMMANDS = {SCRIPT_COMMAND: {}}
[docs]class UnknownCommandError(ConfigurationError):
"""Indicates that a specified command is unknown."""
message_fmt = ("%(value)r is not a valid TAU command.\n"
"\n"
"%(hints)s")
[docs]class AmbiguousCommandError(ConfigurationError):
"""Indicates that a specified partial command is ambiguous."""
message_fmt = ("Command '%(value)s' is ambiguous.\n"
"\n"
"%(hints)s")
def __init__(self, value, matches, *hints):
parts = [f"Did you mean `{SCRIPT_COMMAND} {match}`?" for match in matches]
parts.append("Try `%s --help`" % SCRIPT_COMMAND)
super().__init__(value, *hints + tuple(parts))
[docs]def _command_as_list(module_name):
"""Converts a module name to a command name list.
Maps command module names to their command line equivilants, e.g.
'taucmdr.cli.commands.target.create' => ['tau', 'target', 'create']
Args:
module_name (str): Name of a module.
Returns:
list: Strings that identify the command.
"""
parts = module_name.split('.')
for part in COMMANDS_PACKAGE_NAME.split('.'):
if parts[0] == part:
parts = parts[1:]
return [SCRIPT_COMMAND] + parts
[docs]def _get_commands(package_name):
# pylint: disable=line-too-long
"""Returns a dictionary mapping commands to Python modules.
Given a root module name, return a dictionary that maps commands and their
subcommands to Python modules. The special key ``__module__`` maps to the
command module. Other strings map to subcommands of the command.
Args:
package_name (str): A string naming the module to search for cli.
Returns:
dict: Strings mapping to dictionaries or modules.
Example:
::
_get_commands('taucmdr.cli.commands.target') ==>
{'__module__': <module 'taucmdr.cli.commands.target' from '/home/jlinford/workspace/taucmdr/packages/taucmdr/cli/commands/target/__init__.pyc'>,
'create': {'__module__': <module 'taucmdr.cli.commands.target.create' from '/home/jlinford/workspace/taucmdr/packages/taucmdr/cli/commands/target/create.pyc'>},
'delete': {'__module__': <module 'taucmdr.cli.commands.target.delete' from '/home/jlinford/workspace/taucmdr/packages/taucmdr/cli/commands/target/delete.pyc'>},
'edit': {'__module__': <module 'taucmdr.cli.commands.target.edit' from '/home/jlinford/workspace/taucmdr/packages/taucmdr/cli/commands/target/edit.pyc'>},
'list': {'__module__': <module 'taucmdr.cli.commands.target.list' from '/home/jlinford/workspace/taucmdr/packages/taucmdr/cli/commands/target/list.pyc'>}}
"""
def lookup(cmd, dct):
if not cmd:
return dct
if len(cmd) == 1:
return dct[cmd[0]]
return lookup(cmd[1:], dct[cmd[0]])
def walking_import(module, cmd, dct):
car, cdr = cmd[0], cmd[1:]
if cdr:
walking_import(module, cdr, dct[car])
elif car not in dct:
__import__(module)
dct.setdefault(car, {})['__module__'] = sys.modules[module]
__import__(COMMANDS_PACKAGE_NAME)
command_module = sys.modules[COMMANDS_PACKAGE_NAME]
for _, module, _ in pkgutil.walk_packages(command_module.__path__, prefix=command_module.__name__+'.'):
if not (module.endswith('__main__') or '.tests' in module):
try:
lookup(_command_as_list(module), _COMMANDS)
except KeyError:
walking_import(module, _command_as_list(module), _COMMANDS)
return lookup(_command_as_list(package_name), _COMMANDS)
[docs]def command_from_module_name(module_name):
"""Converts a module name to a command name string.
Maps command module names to their command line equivilants, e.g.
'taucmdr.cli.commands.target.create' => 'tau target create'
Args:
module_name (str): Name of a module.
Returns:
str: A string that identifies the command.
"""
if module_name == '__main__':
return os.path.basename(TAUCMDR_SCRIPT)
return ' '.join(_command_as_list(module_name))
[docs]def commands_description(package_name=COMMANDS_PACKAGE_NAME):
"""Builds listing of command names with short description.
Args:
package_name (str): A dot-seperated string naming the module to search for cli.
Returns:
str: Help string describing all commands found at or below `root`.
"""
usage_fmt = USAGE_FORMAT.lower()
groups = {}
commands = sorted([i for i in _get_commands(package_name).items() if i[0] != '__module__'])
for cmd, topcmd in commands:
module = topcmd['__module__']
try:
command_obj = module.COMMAND
except AttributeError:
continue
descr = command_obj.summary.split('\n')[0]
group = command_obj.group
if usage_fmt == 'console':
line = ' {}{}'.format(util.color_text(f'{cmd:<14}', 'green'), descr)
elif usage_fmt == 'markdown':
line = ' {} | {}'.format(f'{cmd:<28}', descr)
groups.setdefault(group, []).append(line)
parts = []
for group, members in groups.items():
title = group.title() + ' Subcommands' if group else 'Subcommands'
if usage_fmt == 'console':
parts.append(util.color_text(title+':', attrs=['bold']))
elif usage_fmt == 'markdown':
parts.extend(['', ' ', f'{title:<30}' + ' | Description',
'{}:| {}'.format('-'*30, '-'*len('Description'))])
parts.extend(members)
parts.append('')
return '\n'.join(parts)
[docs]def get_all_commands(package_name=COMMANDS_PACKAGE_NAME):
"""Builds a list of all commands and subcommands.
Args:
package_name (str): A dot-separated string naming the module to search for cli.
Returns:
list: List of modules corresponding to all commands and subcommands.
"""
all_commands = []
commands = sorted(i for i in _get_commands(package_name).items() if i[0] != '__module__')
for _, topcmd in commands:
for _, mod in topcmd.items():
if isinstance(mod, dict):
all_commands.append(mod['__module__'].__name__)
elif isinstance(mod, ModuleType):
all_commands.append(mod.__name__)
else:
raise InternalError("%s is an invalid module." %mod)
return all_commands
def _resolve(cmd, c, d):
# pylint: disable=invalid-name
if not c:
return []
car, cdr = c[0], c[1:]
try:
matches = [(car, d[car])]
except KeyError:
matches = [i for i in d.items() if i[0].startswith(car)]
if len(matches) == 1:
return [matches[0][0]] + _resolve(cmd, cdr, matches[0][1])
if not matches:
raise UnknownCommandError(' '.join(cmd))
if len(matches) > 1:
raise AmbiguousCommandError(' '.join(cmd), [m[0] for m in matches])
[docs]def find_command(cmd):
"""Import the command module and return its COMMAND member.
Args:
cmd (list): List of strings identifying the command, i.e. from :any:`_command_as_list`.
Raises:
UnknownCommandError: `cmd` is invalid.
AmbiguousCommandError: `cmd` is ambiguous.
Returns:
AbstractCommand: Command object for the subcommand.
"""
if cmd:
root = '.'.join([COMMANDS_PACKAGE_NAME] + cmd)
else:
root = COMMANDS_PACKAGE_NAME
try:
return _get_commands(root)['__module__'].COMMAND
except KeyError:
LOGGER.debug('%r not recognized as a TAU command', cmd)
resolved = _resolve(cmd, cmd, _COMMANDS[SCRIPT_COMMAND])
LOGGER.debug('Resolved ambiguous command %r to %r', cmd, resolved)
return find_command(resolved)
except AttributeError as err:
raise InternalError("'COMMAND' undefined in %r" % cmd) from err
def _permute(cmd, cmd_args):
cmd_len = len(cmd)
full_len = len(cmd) + len(cmd_args)
skip = [x[0] == '-' or os.path.isfile(x) for x in cmd+cmd_args]
yield cmd, cmd_args
for i in range(full_len):
if skip[i]:
continue
for j in range(i+1, full_len):
if skip[j]:
continue
perm = cmd + cmd_args
perm[i], perm[j] = perm[j], perm[i]
yield perm[:cmd_len], perm[cmd_len:]
[docs]def execute_command(cmd, cmd_args=None, parent_module=None):
"""Import the command module and run its main routine.
Partial commands are allowed, e.g. cmd=['tau', 'cli', 'commands', 'app', 'cre'] will resolve
to 'taucmdr.cli.commands.application.create'. If the command can't be found then the parent
command (if any) will be invoked with the ``--help`` flag.
Args:
cmd (list): List of strings identifying the command, i.e. from :any:`_command_as_list`.
cmd_args (list): Command line arguments to be parsed by command.
parent_module (str): Dot-seperated name of the command's parent.
Raises:
UnknownCommandError: `cmd` is invalid.
AmbiguousCommandError: `cmd` is ambiguous.
Returns:
int: Command return code.
"""
if cmd_args is None:
cmd_args = []
if parent_module:
parent = _command_as_list(parent_module)[1:]
cmd = parent + cmd
for perm_cmd, perm_args in _permute(cmd, cmd_args):
LOGGER.debug('Trying %s(%s)', perm_cmd, perm_args)
try:
main = find_command(perm_cmd).main
except UnknownCommandError:
continue
return main(perm_args)
if len(cmd) <= 1:
# We finally give up
LOGGER.debug("Unknown command %r has no parent module: giving up.", cmd)
raise UnknownCommandError(' '.join(cmd))
if not parent_module:
parent = cmd[:-1]
LOGGER.debug('Getting help from parent command %r', parent)
parent_usage = util.uncolor_text(find_command(parent).usage)
LOGGER.error("Invalid %s subcommand: %s\n\n%s", parent[0], cmd[-1], parent_usage)
return EXIT_FAILURE