summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xcmd2/argparse_completer.py23
-rw-r--r--cmd2/cmd2.py563
-rw-r--r--cmd2/parsing.py73
-rw-r--r--cmd2/utils.py6
-rw-r--r--tests/conftest.py10
-rw-r--r--tests/test_autocompletion.py4
-rw-r--r--tests/test_cmd2.py124
-rw-r--r--tests/test_pyscript.py2
-rw-r--r--tests/transcripts/from_cmdloop.txt4
9 files changed, 587 insertions, 222 deletions
diff --git a/cmd2/argparse_completer.py b/cmd2/argparse_completer.py
index dc9baf7a..ca50bba9 100755
--- a/cmd2/argparse_completer.py
+++ b/cmd2/argparse_completer.py
@@ -750,7 +750,7 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter):
# build full usage string
format = self._format_actions_usage
- action_usage = format(positionals + required_options + optionals, groups)
+ action_usage = format(required_options + optionals + positionals, groups)
usage = ' '.join([s for s in [prog, action_usage] if s])
# wrap the usage parts if it's too long
@@ -761,15 +761,15 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter):
# break usage into wrappable parts
part_regexp = r'\(.*?\)+|\[.*?\]+|\S+'
+ req_usage = format(required_options, groups)
opt_usage = format(optionals, groups)
pos_usage = format(positionals, groups)
- req_usage = format(required_options, groups)
+ req_parts = _re.findall(part_regexp, req_usage)
opt_parts = _re.findall(part_regexp, opt_usage)
pos_parts = _re.findall(part_regexp, pos_usage)
- req_parts = _re.findall(part_regexp, req_usage)
+ assert ' '.join(req_parts) == req_usage
assert ' '.join(opt_parts) == opt_usage
assert ' '.join(pos_parts) == pos_usage
- assert ' '.join(req_parts) == req_usage
# End cmd2 customization
@@ -799,13 +799,15 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter):
if len(prefix) + len(prog) <= 0.75 * text_width:
indent = ' ' * (len(prefix) + len(prog) + 1)
# Begin cmd2 customization
- if opt_parts:
- lines = get_lines([prog] + pos_parts, indent, prefix)
- lines.extend(get_lines(req_parts, indent))
+ if req_parts:
+ lines = get_lines([prog] + req_parts, indent, prefix)
lines.extend(get_lines(opt_parts, indent))
+ lines.extend(get_lines(pos_parts, indent))
+ elif opt_parts:
+ lines = get_lines([prog] + opt_parts, indent, prefix)
+ lines.extend(get_lines(pos_parts, indent))
elif pos_parts:
lines = get_lines([prog] + pos_parts, indent, prefix)
- lines.extend(get_lines(req_parts, indent))
else:
lines = [prog]
# End cmd2 customization
@@ -818,9 +820,9 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter):
lines = get_lines(parts, indent)
if len(lines) > 1:
lines = []
- lines.extend(get_lines(pos_parts, indent))
lines.extend(get_lines(req_parts, indent))
lines.extend(get_lines(opt_parts, indent))
+ lines.extend(get_lines(pos_parts, indent))
# End cmd2 customization
lines = [prog] + lines
@@ -889,6 +891,9 @@ class ACHelpFormatter(argparse.RawTextHelpFormatter):
result = super()._format_args(action, default_metavar)
return result
+ def format_help(self):
+ return super().format_help() + '\n'
+
# noinspection PyCompatibility
class ACArgumentParser(argparse.ArgumentParser):
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 7d8ac7dc..8b7262a2 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -48,7 +48,8 @@ from . import utils
from . import plugin
from .argparse_completer import AutoCompleter, ACArgumentParser, ACTION_ARG_CHOICES
from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer
-from .parsing import StatementParser, Statement
+from .parsing import StatementParser, Statement, Macro, MacroArg, \
+ macro_normal_arg_pattern, macro_escaped_arg_pattern, digit_pattern
# Set up readline
from .rl_utils import rl_type, RlType, rl_get_point, rl_set_prompt, vt100_support, rl_make_safe_prompt
@@ -138,19 +139,21 @@ def categorize(func: Union[Callable, Iterable], category: str) -> None:
setattr(func, HELP_CATEGORY, category)
-def parse_quoted_string(cmdline: str) -> List[str]:
- """Parse a quoted string into a list of arguments."""
- if isinstance(cmdline, list):
+def parse_quoted_string(string: str, preserve_quotes: bool) -> List[str]:
+ """
+ Parse a quoted string into a list of arguments
+ :param string: the string being parsed
+ :param preserve_quotes: if True, then quotes will not be stripped
+ """
+ if isinstance(string, list):
# arguments are already a list, return the list we were passed
- lexed_arglist = cmdline
+ lexed_arglist = string
else:
# Use shlex to split the command line into a list of arguments based on shell rules
- lexed_arglist = shlex.split(cmdline, posix=False)
- # strip off outer quotes for convenience
- temp_arglist = []
- for arg in lexed_arglist:
- temp_arglist.append(utils.strip_quotes(arg))
- lexed_arglist = temp_arglist
+ lexed_arglist = shlex.split(string, posix=False)
+
+ if not preserve_quotes:
+ lexed_arglist = [utils.strip_quotes(arg) for arg in lexed_arglist]
return lexed_arglist
@@ -162,7 +165,7 @@ def with_category(category: str) -> Callable:
return cat_decorator
-def with_argument_list(func: Callable) -> Callable:
+def with_argument_list(func: Callable, preserve_quotes: bool=False) -> Callable:
"""A decorator to alter the arguments passed to a do_* cmd2
method. Default passes a string of whatever the user typed.
With this decorator, the decorated method will receive a list
@@ -171,18 +174,19 @@ def with_argument_list(func: Callable) -> Callable:
@functools.wraps(func)
def cmd_wrapper(self, cmdline):
- lexed_arglist = parse_quoted_string(cmdline)
+ lexed_arglist = parse_quoted_string(cmdline, preserve_quotes)
return func(self, lexed_arglist)
cmd_wrapper.__doc__ = func.__doc__
return cmd_wrapper
-def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Callable:
+def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, preserve_quotes: bool=False) -> Callable:
"""A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given
instance of argparse.ArgumentParser, but also returning unknown args as a list.
:param argparser: given instance of ArgumentParser
+ :param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes
:return: function that gets passed parsed args and a list of unknown args
"""
import functools
@@ -191,7 +195,7 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Calla
def arg_decorator(func: Callable):
@functools.wraps(func)
def cmd_wrapper(instance, cmdline):
- lexed_arglist = parse_quoted_string(cmdline)
+ lexed_arglist = parse_quoted_string(cmdline, preserve_quotes)
try:
args, unknown = argparser.parse_known_args(lexed_arglist)
except SystemExit:
@@ -220,11 +224,12 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Calla
return arg_decorator
-def with_argparser(argparser: argparse.ArgumentParser) -> Callable:
+def with_argparser(argparser: argparse.ArgumentParser, preserve_quotes: bool=False) -> Callable:
"""A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments
with the given instance of argparse.ArgumentParser.
:param argparser: given instance of ArgumentParser
+ :param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes
:return: function that gets passed parsed args
"""
import functools
@@ -233,7 +238,7 @@ def with_argparser(argparser: argparse.ArgumentParser) -> Callable:
def arg_decorator(func: Callable):
@functools.wraps(func)
def cmd_wrapper(instance, cmdline):
- lexed_arglist = parse_quoted_string(cmdline)
+ lexed_arglist = parse_quoted_string(cmdline, preserve_quotes)
try:
args = argparser.parse_args(lexed_arglist)
except SystemExit:
@@ -308,6 +313,7 @@ class Cmd(cmd.Cmd):
multiline_commands = []
shortcuts = {'?': 'help', '!': 'shell', '@': 'load', '@@': '_relative_load'}
aliases = dict()
+ macros = dict()
terminators = [';']
# Attributes which are NOT dynamically settable at runtime
@@ -1476,6 +1482,10 @@ class Cmd(cmd.Cmd):
except AttributeError:
compfunc = self.completedefault
+ # Check if a macro was entered
+ elif command in self.macros:
+ compfunc = self.path_complete
+
# A valid command was not entered
else:
# Check if this command should be run as a shell command
@@ -1543,11 +1553,9 @@ class Cmd(cmd.Cmd):
[shortcut_to_restore + match for match in self.completion_matches]
else:
- # Complete token against aliases and command names
- alias_names = set(self.aliases.keys())
- visible_commands = set(self.get_visible_commands())
- strs_to_match = list(alias_names | visible_commands)
- self.completion_matches = self.basic_complete(text, line, begidx, endidx, strs_to_match)
+ # Complete token against anything a user can run
+ self.completion_matches = self.basic_complete(text, line, begidx, endidx,
+ self.get_commands_aliases_and_macros_for_completion())
# Handle single result
if len(self.completion_matches) == 1:
@@ -1601,6 +1609,13 @@ class Cmd(cmd.Cmd):
return commands
+ def get_commands_aliases_and_macros_for_completion(self) -> List[str]:
+ """Return a list of visible commands, aliases, and macros for tab completion"""
+ visible_commands = set(self.get_visible_commands())
+ alias_names = set(self.aliases)
+ macro_names = set(self.macros)
+ return list(visible_commands | alias_names | macro_names)
+
def get_help_topics(self) -> List[str]:
""" Returns a list of help topics """
return [name[5:] for name in self.get_names()
@@ -1991,7 +2006,7 @@ class Cmd(cmd.Cmd):
result = target
return result
- def onecmd(self, statement: Union[Statement, str]) -> Optional[bool]:
+ def onecmd(self, statement: Union[Statement, str]) -> bool:
""" This executes the actual do_* method for a command.
If the command provided doesn't exist, then it executes _default() instead.
@@ -2004,24 +2019,65 @@ class Cmd(cmd.Cmd):
if not isinstance(statement, Statement):
statement = self._complete_statement(statement)
- funcname = self._func_named(statement.command)
- if not funcname:
- self.default(statement)
- return
+ # Check if this is a macro
+ if statement.command in self.macros:
+ stop = self._run_macro(statement)
+ else:
+ funcname = self._func_named(statement.command)
+ if not funcname:
+ self.default(statement)
+ return False
- # Since we have a valid command store it in the history
- if statement.command not in self.exclude_from_history:
- self.history.append(statement.raw)
+ # Since we have a valid command store it in the history
+ if statement.command not in self.exclude_from_history:
+ self.history.append(statement.raw)
- try:
- func = getattr(self, funcname)
- except AttributeError:
- self.default(statement)
- return
+ try:
+ func = getattr(self, funcname)
+ except AttributeError:
+ self.default(statement)
+ return False
+
+ stop = func(statement)
- stop = func(statement)
return stop
+ def _run_macro(self, statement: Statement) -> bool:
+ """
+ Resolves a macro and runs the resulting string
+
+ :param statement: the parsed statement from the command line
+ :return: a flag indicating whether the interpretation of commands should stop
+ """
+ try:
+ macro = self.macros[statement.command]
+ except KeyError:
+ raise KeyError("{} is not a macro".format(statement.command))
+
+ # Confirm we have the correct number of arguments
+ if len(statement.arg_list) != macro.required_arg_count:
+ self.perror('The macro {!r} expects {} argument(s)'.format(statement.command, macro.required_arg_count),
+ traceback_war=False)
+ return False
+
+ # Resolve the arguments in reverse
+ resolved = macro.value
+ reverse_arg_list = sorted(macro.arg_list, key=lambda ma: ma.start_index, reverse=True)
+
+ for arg in reverse_arg_list:
+ if arg.is_escaped:
+ to_replace = '{{' + str(arg.number) + '}}'
+ replacement = '{' + str(arg.number) + '}'
+ else:
+ to_replace = '{' + str(arg.number) + '}'
+ replacement = statement.argv[arg.number]
+
+ parts = resolved.rsplit(to_replace, maxsplit=1)
+ resolved = parts[0] + replacement + parts[1]
+
+ # Run the resolved command
+ return self.onecmd_plus_hooks(resolved)
+
def default(self, statement: Statement) -> None:
"""Executed when the command given isn't a recognized command implemented by a do_* method.
@@ -2174,120 +2230,365 @@ class Cmd(cmd.Cmd):
return stop
- def do_alias(self, statement: Statement) -> None:
- """Define or display aliases
+ # ----- Alias subcommand functions -----
- Usage: alias [name] | [<name> <value>]
- Where:
- name - name of the alias being looked up, added, or replaced
- value - what the alias will be resolved to (if adding or replacing)
- this can contain spaces and does not need to be quoted
+ def alias_create(self, args: argparse.Namespace):
+ """ Creates or overwrites an alias """
+ # Validate the alias name
+ valid, errmsg = self.statement_parser.is_valid_command(args.name, allow_shortcut=False)
+ if not valid:
+ errmsg = "Invalid alias name: {}".format(errmsg)
+ self.perror(errmsg, traceback_war=False)
+ return
- Without arguments, 'alias' prints a list of all aliases in a reusable form which
- can be outputted to a startup_script to preserve aliases across sessions.
+ if args.name in self.macros:
+ errmsg = "Aliases cannot have the same name as a macro"
+ self.perror(errmsg, traceback_war=False)
+ return
- With one argument, 'alias' shows the value of the specified alias.
- Example: alias ls (Prints the value of the alias called 'ls' if it exists)
+ valid, errmsg = self.statement_parser.is_valid_command(args.command, allow_shortcut=True)
+ if not valid:
+ errmsg = "Invalid alias target: {}".format(errmsg)
+ self.perror(errmsg, traceback_war=False)
+ return
- With two or more arguments, 'alias' creates or replaces an alias.
+ # Unquote redirection and pipes
+ for i, arg in enumerate(args.command_args):
+ unquoted_arg = utils.strip_quotes(arg)
+ if unquoted_arg in constants.REDIRECTION_TOKENS:
+ args.command_args[i] = unquoted_arg
+
+ # Build the alias value string
+ value = utils.quote_string_if_needed(args.command)
+ for cur_arg in args.command_args:
+ value += ' ' + utils.quote_string_if_needed(cur_arg)
+
+ # Set the alias
+ result = "overwritten" if args.name in self.aliases else "created"
+ self.aliases[args.name] = value
+ self.poutput("Alias {!r} {}".format(args.name, result))
+
+ def alias_delete(self, args: argparse.Namespace):
+ """ Deletes aliases """
+ if args.all:
+ self.aliases.clear()
+ self.poutput("All aliases deleted")
+ elif not args.name:
+ self.do_help(['alias', 'delete'])
+ else:
+ # Get rid of duplicates
+ aliases_to_delete = utils.remove_duplicates(args.name)
+
+ for cur_arg in aliases_to_delete:
+ if cur_arg in self.aliases:
+ del self.aliases[cur_arg]
+ self.poutput("Alias {!r} deleted".format(cur_arg))
+ else:
+ self.perror("Alias {!r} does not exist".format(cur_arg), traceback_war=False)
- Example: alias ls !ls -lF
+ def alias_list(self, args: argparse.Namespace):
+ """ Lists some or all aliases """
+ if args.name:
+ names_to_view = utils.remove_duplicates(args.name)
+ for cur_name in names_to_view:
+ if cur_name in self.aliases:
+ self.poutput("alias create {} {}".format(cur_name, self.aliases[cur_name]))
+ else:
+ self.perror("Alias {!r} not found".format(cur_name), traceback_war=False)
+ else:
+ sorted_aliases = utils.alphabetical_sort(self.aliases)
+ for cur_alias in sorted_aliases:
+ self.poutput("alias create {} {}".format(cur_alias, self.aliases[cur_alias]))
+
+ # Top-level parser for alias
+ alias_description = ("Manage aliases\n"
+ "\n"
+ "An alias is a command that enables replacement of a word by another string.")
+ alias_epilog = ("See also:\n"
+ " macro")
+ alias_parser = ACArgumentParser(description=alias_description, epilog=alias_epilog, prog='alias')
+
+ # Add subcommands to alias
+ alias_subparsers = alias_parser.add_subparsers()
+
+ # alias -> create
+ alias_create_help = "create or overwrite an alias"
+ alias_create_description = "Create or overwrite an alias"
+
+ alias_create_epilog = ("Notes:\n"
+ " If you want to use redirection or pipes in the alias, then quote them to prevent\n"
+ " the 'alias create' command itself from being redirected\n"
+ "\n"
+ " Since aliases are resolved during parsing, tab completion will function as it would\n"
+ " for the actual command the alias resolves to."
+ "\n"
+ "Examples:\n"
+ " alias ls !ls -lF\n"
+ " alias create show_log !cat \"log file.txt\"\n"
+ " alias create save_results print_results \">\" out.txt\n")
+
+ alias_create_parser = alias_subparsers.add_parser('create', help=alias_create_help,
+ description=alias_create_description,
+ epilog=alias_create_epilog)
+ setattr(alias_create_parser.add_argument('name', type=str, help='Name of this alias'),
+ ACTION_ARG_CHOICES, get_commands_aliases_and_macros_for_completion)
+ setattr(alias_create_parser.add_argument('command', type=str, help='what the alias resolves to'),
+ ACTION_ARG_CHOICES, get_commands_aliases_and_macros_for_completion)
+ setattr(alias_create_parser.add_argument('command_args', type=str, nargs=argparse.REMAINDER,
+ help='arguments being passed to command'),
+ ACTION_ARG_CHOICES, ('path_complete',))
+ alias_create_parser.set_defaults(func=alias_create)
+
+ # alias -> delete
+ alias_delete_help = "delete aliases"
+ alias_delete_description = "Delete specified aliases or all aliases if --all is used"
+ alias_delete_parser = alias_subparsers.add_parser('delete', help=alias_delete_help,
+ description=alias_delete_description)
+ setattr(alias_delete_parser.add_argument('name', type=str, nargs='*', help='alias to delete'),
+ ACTION_ARG_CHOICES, aliases)
+ alias_delete_parser.add_argument('-a', '--all', action='store_true', help="all aliases will be deleted")
+ alias_delete_parser.set_defaults(func=alias_delete)
+
+ # alias -> list
+ alias_list_help = "list aliases"
+ alias_list_description = ("List specified aliases in a reusable form that can be saved to\n"
+ "a startup_script to preserve aliases across sessions\n"
+ "\n"
+ "Without arguments, all aliases will be listed")
+
+ alias_list_parser = alias_subparsers.add_parser('list', help=alias_list_help,
+ description=alias_list_description)
+ setattr(alias_list_parser.add_argument('name', type=str, nargs="*", help='alias to list'),
+ ACTION_ARG_CHOICES, aliases)
+ alias_list_parser.set_defaults(func=alias_list)
+
+ @with_argparser(alias_parser, preserve_quotes=True)
+ def do_alias(self, args: argparse.Namespace):
+ """Manage aliases"""
+ func = getattr(args, 'func', None)
+ if func is not None:
+ # Call whatever subcommand function was selected
+ func(self, args)
+ else:
+ # No subcommand was provided, so call help
+ self.alias_parser.print_help()
+
+ # ----- Macro subcommand functions -----
+
+ def macro_create(self, args: argparse.Namespace):
+ """ Creates or overwrites a macro """
+ # Validate the macro name
+ valid, errmsg = self.statement_parser.is_valid_command(args.name, allow_shortcut=False)
+ if not valid:
+ errmsg = "Invalid macro name: {}".format(errmsg)
+ self.perror(errmsg, traceback_war=False)
+ return
- If you want to use redirection or pipes in the alias, then quote them to prevent
- the alias command itself from being redirected
+ if args.name in self.get_all_commands():
+ errmsg = "Macros cannot have the same name as a command"
+ self.perror(errmsg, traceback_war=False)
+ return
- Examples:
- alias save_results print_results ">" out.txt
- alias save_results print_results '>' out.txt
-"""
- # Get alias arguments as a list with quotes preserved
- alias_arg_list = statement.arg_list
+ if args.name in self.aliases:
+ errmsg = "Macros cannot have the same name as an alias"
+ self.perror(errmsg, traceback_war=False)
+ return
- # If no args were given, then print a list of current aliases
- if not alias_arg_list:
- sorted_aliases = utils.alphabetical_sort(list(self.aliases))
- for cur_alias in sorted_aliases:
- self.poutput("alias {} {}".format(cur_alias, self.aliases[cur_alias]))
+ valid, errmsg = self.statement_parser.is_valid_command(args.command, allow_shortcut=True)
+ if not valid:
+ errmsg = "Invalid macro target: {}".format(errmsg)
+ self.perror(errmsg, traceback_war=False)
return
- # Get the alias name
- name = alias_arg_list[0]
+ # Unquote redirection and pipes
+ for i, arg in enumerate(args.command_args):
+ unquoted_arg = utils.strip_quotes(arg)
+ if unquoted_arg in constants.REDIRECTION_TOKENS:
+ args.command_args[i] = unquoted_arg
- # The user is looking up an alias
- if len(alias_arg_list) == 1:
- if name in self.aliases:
- self.poutput("alias {} {}".format(name, self.aliases[name]))
- else:
- self.perror("Alias {!r} not found".format(name), traceback_war=False)
+ # Build the macro value string
+ value = utils.quote_string_if_needed(args.command)
+ for cur_arg in args.command_args:
+ value += ' ' + utils.quote_string_if_needed(cur_arg)
- # The user is creating an alias
- else:
- # Unquote redirection and pipes
- index = 1
- while index < len(alias_arg_list):
- unquoted_arg = utils.strip_quotes(alias_arg_list[index])
- if unquoted_arg in constants.REDIRECTION_TOKENS:
- alias_arg_list[index] = unquoted_arg
- index += 1
-
- # Build the alias value string
- value = ' '.join(alias_arg_list[1:])
-
- # Validate the alias to ensure it doesn't include weird characters
- # like terminators, output redirection, or whitespace
- valid, invalidchars = self.statement_parser.is_valid_command(name)
- if valid:
- # Set the alias
- self.aliases[name] = value
- self.poutput("Alias {!r} created".format(name))
- else:
- errmsg = "Aliases can not contain: {}".format(invalidchars)
- self.perror(errmsg, traceback_war=False)
+ # Find all normal arguments
+ arg_list = []
+ normal_matches = re.finditer(macro_normal_arg_pattern, value)
+ max_arg_num = 0
+ num_set = set()
- def complete_alias(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
- """ Tab completion for alias """
- alias_names = set(self.aliases.keys())
- visible_commands = set(self.get_visible_commands())
+ while True:
+ try:
+ cur_match = normal_matches.__next__()
- index_dict = \
- {
- 1: alias_names,
- 2: list(alias_names | visible_commands)
- }
- return self.index_based_complete(text, line, begidx, endidx, index_dict, self.path_complete)
+ # Get the number between the braces
+ cur_num = int(re.findall(digit_pattern, cur_match.group())[0])
+ if cur_num < 1:
+ self.perror("Argument numbers must be greater than 0", traceback_war=False)
+ return
- @with_argument_list
- def do_unalias(self, arglist: List[str]) -> None:
- """Unsets aliases
+ num_set.add(cur_num)
+ if cur_num > max_arg_num:
+ max_arg_num = cur_num
- Usage: unalias [-a] name [name ...]
- Where:
- name - name of the alias being unset
+ arg_list.append(MacroArg(start_index=cur_match.start(), number=cur_num, is_escaped=False))
- Options:
- -a remove all alias definitions
-"""
- if not arglist:
- self.do_help(['unalias'])
+ except StopIteration:
+ break
- if '-a' in arglist:
- self.aliases.clear()
- self.poutput("All aliases cleared")
+ # Make sure the argument numbers are continuous
+ if len(num_set) != max_arg_num:
+ self.perror("Not all numbers between 1 and {} are present "
+ "in the argument placeholders".format(max_arg_num), traceback_war=False)
+ return
+
+ # Find all escaped arguments
+ escaped_matches = re.finditer(macro_escaped_arg_pattern, value)
+
+ while True:
+ try:
+ cur_match = escaped_matches.__next__()
+
+ # Get the number between the braces
+ cur_num = int(re.findall(digit_pattern, cur_match.group())[0])
+ arg_list.append(MacroArg(start_index=cur_match.start(), number=cur_num, is_escaped=True))
+ except StopIteration:
+ break
+
+ # Set the macro
+ result = "overwritten" if args.name in self.macros else "created"
+ self.macros[args.name] = Macro(name=args.name, value=value, required_arg_count=max_arg_num, arg_list=arg_list)
+ self.poutput("Macro {!r} {}".format(args.name, result))
+
+ def macro_delete(self, args: argparse.Namespace):
+ """ Deletes macros """
+ if args.all:
+ self.macros.clear()
+ self.poutput("All macros deleted")
+ elif not args.name:
+ self.do_help(['macro', 'delete'])
else:
# Get rid of duplicates
- arglist = utils.remove_duplicates(arglist)
+ macros_to_delete = utils.remove_duplicates(args.name)
- for cur_arg in arglist:
- if cur_arg in self.aliases:
- del self.aliases[cur_arg]
- self.poutput("Alias {!r} cleared".format(cur_arg))
+ for cur_arg in macros_to_delete:
+ if cur_arg in self.macros:
+ del self.macros[cur_arg]
+ self.poutput("Macro {!r} cleared".format(cur_arg))
else:
- self.perror("Alias {!r} does not exist".format(cur_arg), traceback_war=False)
-
- def complete_unalias(self, text: str, line: str, begidx: int, endidx: int) -> List[str]:
- """ Tab completion for unalias """
- return self.basic_complete(text, line, begidx, endidx, self.aliases)
+ self.perror("Macro {!r} does not exist".format(cur_arg), traceback_war=False)
+
+ def macro_list(self, args: argparse.Namespace):
+ """ Lists some or all macros """
+ if args.name:
+ names_to_view = utils.remove_duplicates(args.name)
+ for cur_name in names_to_view:
+ if cur_name in self.macros:
+ self.poutput("macro create {} {}".format(cur_name, self.macros[cur_name].value))
+ else:
+ self.perror("Macro {!r} not found".format(cur_name), traceback_war=False)
+ else:
+ sorted_macros = utils.alphabetical_sort(self.macros)
+ for cur_macro in sorted_macros:
+ self.poutput("macro create {} {}".format(cur_macro, self.macros[cur_macro].value))
+
+ # Top-level parser for macro
+ macro_description = ("Manage macros\n"
+ "\n"
+ "A macro is similar to an alias, but it can take arguments when called.")
+ macro_epilog = ("See also:\n"
+ " alias")
+ macro_parser = ACArgumentParser(description=macro_description, epilog=macro_epilog, prog='macro')
+
+ # Add subcommands to macro
+ macro_subparsers = macro_parser.add_subparsers()
+
+ # macro -> create
+ macro_create_help = "create or overwrite a macro"
+ macro_create_description = "Create or overwrite a macro"
+
+ macro_create_epilog = ("A macro is similar to an alias, but it can take arguments when called. Arguments are\n"
+ "expressed when creating a macro using {#} notation where {1} means the first argument.\n"
+ "\n"
+ "The following creates a macro called my_macro that expects two arguments:\n"
+ "\n"
+ " macro create my_macro make_dinner -meat {1} -veggie {2}\n"
+ "\n"
+ "When the macro is called, the provided arguments are resolved and the assembled\n"
+ "command is run. For example:\n"
+ "\n"
+ " my_macro beef broccoli ---> make_dinner -meat beef -veggie broccoli\n"
+ "\n"
+ "Notes:\n"
+ " To use the literal string {1} in your command, escape it this way: {{1}}.\n"
+ "\n"
+ " An argument number can be repeated in a macro. In the following example the first\n"
+ " argument will populate both {1} instances.\n"
+ "\n"
+ " macro create ft file_taxes -p {1} -q {2} -r {1}\n"
+ "\n"
+ " To quote an argument in the resolved command, quote it during creation.\n"
+ "\n"
+ " macro create backup !cp \"{1}\" \"{1}.orig\"\n"
+ "\n"
+ " Macros can resolve into commands, aliases, and other macros. Therefore it is\n"
+ " possible to create a macro that results in infinite recursion if a macro ends up\n"
+ " resolving back to itself. So be careful.\n"
+ "\n"
+ " If you want to use redirection or pipes in the macro, then quote them as in the\n"
+ " following example to prevent the 'macro create' command itself from being redirected.\n"
+ "\n"
+ " macro create save_results print_results -type {1} \">\" \"{2}\"\n"
+ "\n"
+ " Because macros do not resolve until after parsing (hitting Enter), tab completion\n"
+ " will only complete paths.")
+
+ macro_create_parser = macro_subparsers.add_parser('create', help=macro_create_help,
+ description=macro_create_description,
+ epilog=macro_create_epilog)
+ setattr(macro_create_parser.add_argument('name', type=str, help='Name of this macro'),
+ ACTION_ARG_CHOICES, macros)
+ setattr(macro_create_parser.add_argument('command', type=str, help='what the macro resolves to'),
+ ACTION_ARG_CHOICES, get_commands_aliases_and_macros_for_completion)
+ setattr(macro_create_parser.add_argument('command_args', type=str, nargs=argparse.REMAINDER,
+ help='arguments being passed to command'),
+ ACTION_ARG_CHOICES, ('path_complete',))
+ macro_create_parser.set_defaults(func=macro_create)
+
+ # macro -> delete
+ macro_delete_help = "delete macros"
+ macro_delete_description = "Delete specified macros or all macros if --all is used"
+ macro_delete_parser = macro_subparsers.add_parser('delete', help=macro_delete_help,
+ description=macro_delete_description)
+ setattr(macro_delete_parser.add_argument('name', type=str, nargs='*', help='macro to delete'),
+ ACTION_ARG_CHOICES, macros)
+ macro_delete_parser.add_argument('-a', '--all', action='store_true', help="all macros will be deleted")
+ macro_delete_parser.set_defaults(func=macro_delete)
+
+ # macro -> list
+ macro_list_help = "list macros"
+ macro_list_description = ("List specified macros in a reusable form that can be saved to\n"
+ "a startup_script to preserve macros across sessions\n"
+ "\n"
+ "Without arguments, all macros will be listed.")
+
+ macro_list_parser = macro_subparsers.add_parser('list', help=macro_list_help,
+ description=macro_list_description)
+ setattr(macro_list_parser.add_argument('name', type=str, nargs="*", help='macro to list'),
+ ACTION_ARG_CHOICES, macros)
+ macro_list_parser.set_defaults(func=macro_list)
+
+ @with_argparser(macro_parser, preserve_quotes=True)
+ def do_macro(self, args: argparse.Namespace):
+ """ Manage macros """
+ func = getattr(args, 'func', None)
+ if func is not None:
+ # Call whatever subcommand function was selected
+ func(self, args)
+ else:
+ # No subcommand was provided, so call help
+ self.macro_parser.print_help()
@with_argument_list
def do_help(self, arglist: List[str]) -> None:
@@ -2525,10 +2826,10 @@ class Cmd(cmd.Cmd):
else:
raise LookupError("Parameter '{}' not supported (type 'set' for list of parameters).".format(param))
- set_description = "Sets a settable parameter or shows current settings of parameters.\n"
- set_description += "\n"
- set_description += "Accepts abbreviated parameter names so long as there is no ambiguity.\n"
- set_description += "Call without arguments for a list of settable parameters with their values."
+ set_description = ("Sets a settable parameter or shows current settings of parameters.\n"
+ "\n"
+ "Accepts abbreviated parameter names so long as there is no ambiguity.\n"
+ "Call without arguments for a list of settable parameters with their values.")
set_parser = ACArgumentParser(description=set_description)
set_parser.add_argument('-a', '--all', action='store_true', help='display read-only settings as well')
diff --git a/cmd2/parsing.py b/cmd2/parsing.py
index 1d22ccb8..82e8ee39 100644
--- a/cmd2/parsing.py
+++ b/cmd2/parsing.py
@@ -12,6 +12,51 @@ import attr
from . import constants
from . import utils
+# Pattern used to find normal argument
+# Match strings like: {5}, {{{{{4}, {2}}}}}
+macro_normal_arg_pattern = re.compile(r'(?<!\{)\{\d+\}|\{\d+\}(?!\})')
+
+# Pattern used to find escaped arguments (2 or more braces on each side of digit)
+# Match strings like: {{5}}, {{{{{4}}, {{2}}}}}, {{{4}}}
+macro_escaped_arg_pattern = re.compile(r'\{{2}\d+\}{2}')
+
+# Finds a string of digits
+digit_pattern = re.compile(r'\d+')
+
+
+@attr.s(frozen=True)
+class MacroArg:
+ """
+ Information used to replace or unescape arguments in a macro value when the macro is resolved
+ Normal argument syntax : {5}
+ Escaped argument syntax: {{5}}
+ """
+ # The starting index of this argument in the macro value
+ start_index = attr.ib(validator=attr.validators.instance_of(int), type=int)
+
+ # The number that appears between the braces
+ number = attr.ib(validator=attr.validators.instance_of(int), type=int)
+
+ # Tells if this argument is escaped and therefore needs to be unescaped
+ is_escaped = attr.ib(validator=attr.validators.instance_of(bool), type=bool)
+
+
+@attr.s(frozen=True)
+class Macro:
+ """Defines a cmd2 macro"""
+
+ # Name of the macro
+ name = attr.ib(validator=attr.validators.instance_of(str), type=str)
+
+ # The string the macro resolves to
+ value = attr.ib(validator=attr.validators.instance_of(str), type=str)
+
+ # The required number of args the user has to pass to this macro
+ required_arg_count = attr.ib(validator=attr.validators.instance_of(int), type=int)
+
+ # Used to fill in argument placeholders in the macro
+ arg_list = attr.ib(factory=list, validator=attr.validators.instance_of(list), type=List[MacroArg])
+
@attr.s(frozen=True)
class Statement(str):
@@ -246,24 +291,34 @@ class StatementParser:
expr = r'\A\s*(\S*?)({})'.format(second_group)
self._command_pattern = re.compile(expr)
- def is_valid_command(self, word: str) -> Tuple[bool, str]:
- """Determine whether a word is a valid alias.
+ def is_valid_command(self, word: str, allow_shortcut: bool) -> Tuple[bool, str]:
+ """Determine whether a word is a valid name for a command.
- Aliases can not include redirection characters, whitespace,
- or termination characters.
+ Commands can not include redirection characters, whitespace,
+ or termination characters. They also cannot start with a
+ shortcut.
- If word is not a valid command, return False and a comma
- separated string of characters that can not appear in a command.
+ If word is not a valid command, return False and error text
This string is suitable for inclusion in an error message of your
choice:
- valid, invalidchars = statement_parser.is_valid_command('>')
+ valid, errmsg = statement_parser.is_valid_command('>')
if not valid:
- errmsg = "Aliases can not contain: {}".format(invalidchars)
+ errmsg = "Aliases {}".format(errmsg)
"""
valid = False
- errmsg = 'whitespace, quotes, '
+ if not word:
+ return False, 'cannot be an empty string'
+
+ if not allow_shortcut:
+ errmsg = 'cannot start with a shortcut: '
+ errmsg += ', '.join(shortcut for (shortcut, expansion) in self.shortcuts)
+ for (shortcut, expansion) in self.shortcuts:
+ if word.startswith(shortcut):
+ return False, errmsg
+
+ errmsg = 'cannot contain: whitespace, quotes, '
errchars = []
errchars.extend(constants.REDIRECTION_CHARS)
errchars.extend(self.terminators)
diff --git a/cmd2/utils.py b/cmd2/utils.py
index bdb488cc..3527236f 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -6,7 +6,7 @@ import collections
import os
import re
import unicodedata
-from typing import Any, List, Optional, Union
+from typing import Any, Iterable, List, Optional, Union
from . import constants
@@ -194,7 +194,7 @@ def norm_fold(astr: str) -> str:
return unicodedata.normalize('NFC', astr).casefold()
-def alphabetical_sort(list_to_sort: List[str]) -> List[str]:
+def alphabetical_sort(list_to_sort: Iterable[str]) -> List[str]:
"""Sorts a list of strings alphabetically.
For example: ['a1', 'A11', 'A2', 'a22', 'a3']
@@ -232,7 +232,7 @@ def natural_keys(input_str: str) -> List[Union[int, str]]:
return [try_int_or_force_to_lower_case(substr) for substr in re.split('(\d+)', input_str)]
-def natural_sort(list_to_sort: List[str]) -> List[str]:
+def natural_sort(list_to_sort: Iterable[str]) -> List[str]:
"""
Sorts a list of strings case insensitively as well as numerically.
diff --git a/tests/conftest.py b/tests/conftest.py
index 39aa3473..b86622ac 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -29,29 +29,29 @@ except ImportError:
# Help text for base cmd2.Cmd application
BASE_HELP = """Documented commands (type help <topic>):
========================================
-alias help load pyscript set shortcuts
-edit history py quit shell unalias
+alias help load py quit shell
+edit history macro pyscript set shortcuts
"""
BASE_HELP_VERBOSE = """
Documented commands (type help <topic>):
================================================================================
-alias Define or display aliases
+alias Manage aliases
edit Edit a file in a text editor
help List available commands with "help" or detailed help with "help cmd"
history View, run, edit, save, or clear previously entered commands
load Runs commands in script file that is encoded as either ASCII or UTF-8 text
+macro Manage macros
py Invoke python command, shell, or script
pyscript Runs a python script file inside the console
quit Exits this application
set Sets a settable parameter or shows current settings of parameters
shell Execute a command as if at the OS prompt
shortcuts Lists shortcuts available
-unalias Unsets aliases
"""
# Help text for the history command
-HELP_HISTORY = """Usage: history [arg] [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c]
+HELP_HISTORY = """Usage: history [-h] [-r | -e | -s | -o FILE | -t TRANSCRIPT | -c] [arg]
View, run, edit, save, or clear previously entered commands
diff --git a/tests/test_autocompletion.py b/tests/test_autocompletion.py
index 8035587a..c6c1d1f6 100644
--- a/tests/test_autocompletion.py
+++ b/tests/test_autocompletion.py
@@ -34,9 +34,9 @@ optional arguments:
single value - maximum duration
[a, b] - duration range'''
-MEDIA_MOVIES_ADD_HELP = '''Usage: media movies add title {G, PG, PG-13, R, NC-17} [actor [...]]
- -d DIRECTOR{1..2}
+MEDIA_MOVIES_ADD_HELP = '''Usage: media movies add -d DIRECTOR{1..2}
[-h]
+ title {G, PG, PG-13, R, NC-17} [actor [...]]
positional arguments:
title Movie Title
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index dece1ab4..3cbde311 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -78,7 +78,7 @@ def test_base_invalid_option(base_app, capsys):
out = normalize(out)
err = normalize(err)
assert 'Error: unrecognized arguments: -z' in err[0]
- assert out[0] == 'Usage: set [param] [value] [-h] [-a] [-l]'
+ assert out[0] == 'Usage: set [-h] [-a] [-l] [param] [value]'
def test_base_shortcuts(base_app):
out = run_cmd(base_app, 'shortcuts')
@@ -1159,8 +1159,8 @@ def test_custom_help_menu(help_app):
expected = normalize("""
Documented commands (type help <topic>):
========================================
-alias help load pyscript set shortcuts unalias
-edit history py quit shell squat
+alias help load py quit shell squat
+edit history macro pyscript set shortcuts
Undocumented commands:
======================
@@ -1226,7 +1226,7 @@ diddly
Other
=====
-alias help history load py pyscript quit set shell shortcuts unalias
+alias help history load macro py pyscript quit set shell shortcuts
Undocumented commands:
======================
@@ -1249,17 +1249,17 @@ diddly This command does diddly
Other
================================================================================
-alias Define or display aliases
+alias Manage aliases
help List available commands with "help" or detailed help with "help cmd"
history View, run, edit, save, or clear previously entered commands
load Runs commands in script file that is encoded as either ASCII or UTF-8 text
+macro Manage macros
py Invoke python command, shell, or script
pyscript Runs a python script file inside the console
quit Exits this application
set Sets a settable parameter or shows current settings of parameters
shell Execute a command as if at the OS prompt
shortcuts Lists shortcuts available
-unalias Unsets aliases
Undocumented commands:
======================
@@ -1765,9 +1765,9 @@ def test_poutput_color_never(base_app):
assert out == expected
-def test_alias(base_app, capsys):
+def test_alias_create(base_app, capsys):
# Create the alias
- out = run_cmd(base_app, 'alias fake pyscript')
+ out = run_cmd(base_app, 'alias create fake pyscript')
assert out == normalize("Alias 'fake' created")
# Use the alias
@@ -1776,46 +1776,46 @@ def test_alias(base_app, capsys):
assert "pyscript command requires at least 1 argument" in err
# See a list of aliases
- out = run_cmd(base_app, 'alias')
- assert out == normalize('alias fake pyscript')
+ out = run_cmd(base_app, 'alias list')
+ assert out == normalize('alias create fake pyscript')
- # Lookup the new alias
- out = run_cmd(base_app, 'alias fake')
- assert out == normalize('alias fake pyscript')
+ # Look up the new alias
+ out = run_cmd(base_app, 'alias list fake')
+ assert out == normalize('alias create fake pyscript')
-def test_alias_with_quotes(base_app, capsys):
+def test_alias_create_with_quotes(base_app, capsys):
# Create the alias
- out = run_cmd(base_app, 'alias fake help ">" "out file.txt"')
+ out = run_cmd(base_app, 'alias create fake help ">" "out file.txt"')
assert out == normalize("Alias 'fake' created")
- # Lookup the new alias (Only the redirector should be unquoted)
- out = run_cmd(base_app, 'alias fake')
- assert out == normalize('alias fake help > "out file.txt"')
+ # Look up the new alias (Only the redirector should be unquoted)
+ out = run_cmd(base_app, 'alias list fake')
+ assert out == normalize('alias create fake help > "out file.txt"')
-def test_alias_lookup_invalid_alias(base_app, capsys):
- # Lookup invalid alias
- out = run_cmd(base_app, 'alias invalid')
+@pytest.mark.parametrize('alias_name', [
+ '""', # Blank name
+ '!no_shortcut',
+ '">"',
+ '"no>pe"',
+ '"no spaces"',
+ '"nopipe|"',
+ '"noterm;"',
+ 'noembedded"quotes',
+])
+def test_alias_create_invalid_name(base_app, alias_name, capsys):
+ run_cmd(base_app, 'alias create {} help'.format(alias_name))
out, err = capsys.readouterr()
- assert "not found" in err
-
-def test_unalias(base_app):
- # Create an alias
- run_cmd(base_app, 'alias fake pyscript')
+ assert "Invalid alias name" in err
- # Remove the alias
- out = run_cmd(base_app, 'unalias fake')
- assert out == normalize("Alias 'fake' cleared")
-
-def test_unalias_all(base_app):
- out = run_cmd(base_app, 'unalias -a')
- assert out == normalize("All aliases cleared")
-
-def test_unalias_non_existing(base_app, capsys):
- run_cmd(base_app, 'unalias fake')
+def test_alias_create_with_macro_name(base_app, capsys):
+ macro = "my_macro"
+ run_cmd(base_app, 'macro create {} help'.format(macro))
+ run_cmd(base_app, 'alias create {} help'.format(macro))
out, err = capsys.readouterr()
- assert "does not exist" in err
+ assert "cannot have the same name" in err
-@pytest.mark.parametrize('alias_name', [
+@pytest.mark.parametrize('alias_target', [
+ '""', # Blank name
'">"',
'"no>pe"',
'"no spaces"',
@@ -1823,35 +1823,39 @@ def test_unalias_non_existing(base_app, capsys):
'"noterm;"',
'noembedded"quotes',
])
-def test_create_invalid_alias(base_app, alias_name, capsys):
- run_cmd(base_app, 'alias {} help'.format(alias_name))
+def test_alias_create_with_invalid_command(base_app, alias_target, capsys):
+ run_cmd(base_app, 'alias create my_alias {}'.format(alias_target))
+ out, err = capsys.readouterr()
+ assert "Invalid alias target" in err
+
+def test_alias_list_invalid_alias(base_app, capsys):
+ # Look up invalid alias
+ out = run_cmd(base_app, 'alias list invalid')
out, err = capsys.readouterr()
- assert "can not contain" in err
+ assert "not found" in err
-def test_complete_unalias(base_app):
- text = 'f'
- line = text
- endidx = len(line)
- begidx = endidx - len(text)
+def test_alias_delete(base_app):
+ # Create an alias
+ run_cmd(base_app, 'alias create fake pyscript')
- # Validate there are no completions when there are no aliases
- assert base_app.complete_unalias(text, line, begidx, endidx) == []
+ # Delete the alias
+ out = run_cmd(base_app, 'alias delete fake')
+ assert out == normalize("Alias 'fake' deleted")
- # Create a few aliases - two the start with 'f' and one that doesn't
- run_cmd(base_app, 'alias fall quit')
- run_cmd(base_app, 'alias fake pyscript')
- run_cmd(base_app, 'alias carapace shell')
+def test_alias_delete_all(base_app):
+ out = run_cmd(base_app, 'alias delete --all')
+ assert out == normalize("All aliases deleted")
- # Validate that there are now completions
- expected = ['fake', 'fall']
- result = base_app.complete_unalias(text, line, begidx, endidx)
- assert sorted(expected) == sorted(result)
+def test_alias_delete_non_existing(base_app, capsys):
+ run_cmd(base_app, 'alias delete fake')
+ out, err = capsys.readouterr()
+ assert "does not exist" in err
def test_multiple_aliases(base_app):
alias1 = 'h1'
alias2 = 'h2'
- run_cmd(base_app, 'alias {} help'.format(alias1))
- run_cmd(base_app, 'alias {} help -v'.format(alias2))
+ run_cmd(base_app, 'alias create {} help'.format(alias1))
+ run_cmd(base_app, 'alias create {} help -v'.format(alias2))
out = run_cmd(base_app, alias1)
expected = normalize(BASE_HELP)
assert out == expected
@@ -1985,8 +1989,8 @@ def test_bad_history_file_path(capsys, request):
def test_get_all_commands(base_app):
# Verify that the base app has the expected commands
commands = base_app.get_all_commands()
- expected_commands = ['_relative_load', 'alias', 'edit', 'eof', 'eos', 'help', 'history', 'load', 'py', 'pyscript',
- 'quit', 'set', 'shell', 'shortcuts', 'unalias']
+ expected_commands = ['_relative_load', 'alias', 'edit', 'eof', 'eos', 'help', 'history', 'load', 'macro',
+ 'py', 'pyscript', 'quit', 'set', 'shell', 'shortcuts']
assert commands == expected_commands
def test_get_help_topics(base_app):
diff --git a/tests/test_pyscript.py b/tests/test_pyscript.py
index d5e5a4fb..84abc965 100644
--- a/tests/test_pyscript.py
+++ b/tests/test_pyscript.py
@@ -210,7 +210,7 @@ def test_pyscript_results(ps_app, capsys, request, pyscript_file, exp_out):
@pytest.mark.parametrize('expected, pyscript_file', [
- ("['_relative_load', 'alias', 'bar', 'cmd_echo', 'edit', 'eof', 'eos', 'foo', 'help', 'history', 'load', 'media', 'py', 'pyscript', 'quit', 'set', 'shell', 'shortcuts', 'unalias']",
+ ("['_relative_load', 'alias', 'bar', 'cmd_echo', 'edit', 'eof', 'eos', 'foo', 'help', 'history', 'load', 'macro', 'media', 'py', 'pyscript', 'quit', 'set', 'shell', 'shortcuts']",
'pyscript_dir1.py'),
("['movies', 'shows']", 'pyscript_dir2.py')
])
diff --git a/tests/transcripts/from_cmdloop.txt b/tests/transcripts/from_cmdloop.txt
index 5f22d756..56bbdc0c 100644
--- a/tests/transcripts/from_cmdloop.txt
+++ b/tests/transcripts/from_cmdloop.txt
@@ -5,8 +5,8 @@
Documented commands (type help <topic>):
========================================
-alias help load orate pyscript say shell speak/ */
-edit history mumble py quit set shortcuts unalias/ */
+alias help load mumble py quit set shortcuts/ */
+edit history macro orate pyscript say shell speak/ */
(Cmd) help say
usage: speak [-h] [-p] [-s] [-r REPEAT]/ */