diff --git a/CHANGELOG.md b/CHANGELOG.md
index 87056cee..27cdb5b1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,16 +2,19 @@
- Breaking Changes
- - Removed macros
- No longer setting parser's `prog` value in `with_argparser()` since it gets set in
`Cmd._build_parser()`. This code had previously been restored to support backward
compatibility in `cmd2` 2.0 family.
- Enhancements
+
- Simplified the process to set a custom parser for `cmd2's` built-in commands. See
[custom_parser.py](https://github.com/python-cmd2/cmd2/blob/main/examples/custom_parser.py)
example for more details.
+ - Added `Cmd.macro_arg_complete()` which tab completes arguments to a macro. Its default
+ behavior is to perform path completion, but it can be overridden as needed.
+
## 2.7.0 (June 30, 2025)
- Enhancements
diff --git a/README.md b/README.md
index 2841d53c..bb412042 100755
--- a/README.md
+++ b/README.md
@@ -69,10 +69,10 @@ first pillar of 'ease of command discovery'. The following is a list of features
-cmd2 creates the second pillar of 'ease of transition to automation' through alias creation, command
-line argument parsing and execution of cmd2 scripting.
+cmd2 creates the second pillar of 'ease of transition to automation' through alias/macro creation,
+command line argument parsing and execution of cmd2 scripting.
-- Flexible alias creation for quick abstraction of commands.
+- Flexible alias and macro creation for quick abstraction of commands.
- Text file scripting of your application with `run_script` (`@`) and `_relative_run_script` (`@@`)
- Powerful and flexible built-in Python scripting of your application using the `run_pyscript`
command
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 3e316d80..92a788ff 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -38,6 +38,7 @@
import os
import pprint
import pydoc
+import re
import sys
import tempfile
import threading
@@ -119,6 +120,8 @@
single_line_format,
)
from .parsing import (
+ Macro,
+ MacroArg,
Statement,
StatementParser,
shlex_split,
@@ -428,6 +431,9 @@ def __init__(
# Commands to exclude from the history command
self.exclude_from_history = ['eof', 'history']
+ # Dictionary of macro names and their values
+ self.macros: dict[str, Macro] = {}
+
# Keeps track of typed command history in the Python shell
self._py_history: list[str] = []
@@ -473,7 +479,7 @@ def __init__(
self.help_error = "No help on {}"
# The error that prints when a non-existent command is run
- self.default_error = "{} is not a recognized command or alias."
+ self.default_error = "{} is not a recognized command, alias, or macro."
# If non-empty, this string will be displayed if a broken pipe error occurs
self.broken_pipe_warning = ''
@@ -544,7 +550,7 @@ def __init__(
# If natural sorting is preferred, then set this to NATURAL_SORT_KEY.
# cmd2 uses this key for sorting:
# command and category names
- # alias, settable, and shortcut names
+ # alias, macro, settable, and shortcut names
# tab completion results when self.matches_sorted is False
self.default_sort_key: Callable[[str], str] = Cmd.ALPHABETICAL_SORT_KEY
@@ -817,6 +823,11 @@ def _install_command_function(self, command_func_name: str, command_method: Comm
self.pwarning(f"Deleting alias '{command}' because it shares its name with a new command")
del self.aliases[command]
+ # Check if command shares a name with a macro
+ if command in self.macros:
+ self.pwarning(f"Deleting macro '{command}' because it shares its name with a new command")
+ del self.macros[command]
+
setattr(self, command_func_name, command_method)
def _install_completer_function(self, cmd_name: str, cmd_completer: CompleterFunc) -> None:
@@ -2049,8 +2060,12 @@ def _perform_completion(
# Determine the completer function to use for the command's argument
if custom_settings is None:
+ # Check if a macro was entered
+ if command in self.macros:
+ completer_func = self.macro_arg_complete
+
# Check if a command was entered
- if command in self.get_all_commands():
+ elif command in self.get_all_commands():
# Get the completer function for this command
func_attr = getattr(self, constants.COMPLETER_FUNC_PREFIX + command, None)
@@ -2076,7 +2091,8 @@ def _perform_completion(
else:
completer_func = self.completedefault # type: ignore[assignment]
- # Not a recognized command. Check if it should be run as a shell command.
+ # Not a recognized macro or command
+ # Check if this command should be run as a shell command
elif self.default_to_shell and command in utils.get_exes_in_path(command):
completer_func = self.path_complete
else:
@@ -2234,8 +2250,8 @@ def complete( # type: ignore[override]
parser.add_argument(
'command',
metavar="COMMAND",
- help="command or alias name",
- choices=self._get_commands_and_aliases_for_completion(),
+ help="command, alias, or macro name",
+ choices=self._get_commands_aliases_and_macros_for_completion(),
)
custom_settings = utils.CustomCompletionSettings(parser)
@@ -2323,6 +2339,19 @@ def _get_alias_completion_items(self) -> list[CompletionItem]:
return results
+ # Table displayed when tab completing macros
+ _macro_completion_table = SimpleTable([Column('Value', width=80)], divider_char=None)
+
+ def _get_macro_completion_items(self) -> list[CompletionItem]:
+ """Return list of macro names and values as CompletionItems."""
+ results: list[CompletionItem] = []
+
+ for cur_key in self.macros:
+ row_data = [self.macros[cur_key].value]
+ results.append(CompletionItem(cur_key, self._macro_completion_table.generate_data_row(row_data)))
+
+ return results
+
# Table displayed when tab completing Settables
_settable_completion_table = SimpleTable([Column('Value', width=30), Column('Description', width=60)], divider_char=None)
@@ -2336,11 +2365,12 @@ def _get_settable_completion_items(self) -> list[CompletionItem]:
return results
- def _get_commands_and_aliases_for_completion(self) -> list[str]:
- """Return a list of visible commands and aliases for tab completion."""
+ 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)
- return list(visible_commands | alias_names)
+ macro_names = set(self.macros)
+ return list(visible_commands | alias_names | macro_names)
def get_help_topics(self) -> list[str]:
"""Return a list of help topics."""
@@ -2479,7 +2509,7 @@ def onecmd_plus_hooks(
try:
# Convert the line into a Statement
- statement = self._complete_statement(line, orig_rl_history_length=orig_rl_history_length)
+ statement = self._input_line_to_statement(line, orig_rl_history_length=orig_rl_history_length)
# call the postparsing hooks
postparsing_data = plugin.PostparsingData(False, statement)
@@ -2723,6 +2753,99 @@ def combine_rl_history(statement: Statement) -> None:
return statement
+ def _input_line_to_statement(self, line: str, *, orig_rl_history_length: Optional[int] = None) -> Statement:
+ """Parse the user's input line and convert it to a Statement, ensuring that all macros are also resolved.
+
+ :param line: the line being parsed
+ :param orig_rl_history_length: Optional length of the readline history before the current command was typed.
+ This is used to assist in combining multiline readline history entries and is only
+ populated by cmd2. Defaults to None.
+ :return: parsed command line as a Statement
+ :raises Cmd2ShlexError: if a shlex error occurs (e.g. No closing quotation)
+ :raises EmptyStatement: when the resulting Statement is blank
+ """
+ used_macros = []
+ orig_line = None
+
+ # Continue until all macros are resolved
+ while True:
+ # Make sure all input has been read and convert it to a Statement
+ statement = self._complete_statement(line, orig_rl_history_length=orig_rl_history_length)
+
+ # If this is the first loop iteration, save the original line and stop
+ # combining multiline history entries in the remaining iterations.
+ if orig_line is None:
+ orig_line = statement.raw
+ orig_rl_history_length = None
+
+ # Check if this command matches a macro and wasn't already processed to avoid an infinite loop
+ if statement.command in self.macros and statement.command not in used_macros:
+ used_macros.append(statement.command)
+ resolve_result = self._resolve_macro(statement)
+ if resolve_result is None:
+ raise EmptyStatement
+ line = resolve_result
+ else:
+ break
+
+ # This will be true when a macro was used
+ if orig_line != statement.raw:
+ # Build a Statement that contains the resolved macro line
+ # but the originally typed line for its raw member.
+ statement = Statement(
+ statement.args,
+ raw=orig_line,
+ command=statement.command,
+ arg_list=statement.arg_list,
+ multiline_command=statement.multiline_command,
+ terminator=statement.terminator,
+ suffix=statement.suffix,
+ pipe_to=statement.pipe_to,
+ output=statement.output,
+ output_to=statement.output_to,
+ )
+ return statement
+
+ def _resolve_macro(self, statement: Statement) -> Optional[str]:
+ """Resolve a macro and return the resulting string.
+
+ :param statement: the parsed statement from the command line
+ :return: the resolved macro or None on error
+ """
+ if statement.command not in self.macros:
+ raise KeyError(f"{statement.command} is not a macro")
+
+ macro = self.macros[statement.command]
+
+ # Make sure enough arguments were passed in
+ if len(statement.arg_list) < macro.minimum_arg_count:
+ plural = '' if macro.minimum_arg_count == 1 else 's'
+ self.perror(f"The macro '{statement.command}' expects at least {macro.minimum_arg_count} argument{plural}")
+ return None
+
+ # Resolve the arguments in reverse and read their values from statement.argv since those
+ # are unquoted. Macro args should have been quoted when the macro was created.
+ resolved = macro.value
+ reverse_arg_list = sorted(macro.arg_list, key=lambda ma: ma.start_index, reverse=True)
+
+ for macro_arg in reverse_arg_list:
+ if macro_arg.is_escaped:
+ to_replace = '{{' + macro_arg.number_str + '}}'
+ replacement = '{' + macro_arg.number_str + '}'
+ else:
+ to_replace = '{' + macro_arg.number_str + '}'
+ replacement = statement.argv[int(macro_arg.number_str)]
+
+ parts = resolved.rsplit(to_replace, maxsplit=1)
+ resolved = parts[0] + replacement + parts[1]
+
+ # Append extra arguments and use statement.arg_list since these arguments need their quotes preserved
+ for stmt_arg in statement.arg_list[macro.minimum_arg_count :]:
+ resolved += ' ' + stmt_arg
+
+ # Restore any terminator, suffix, redirection, etc.
+ return resolved + statement.post_command
+
def _redirect_output(self, statement: Statement) -> utils.RedirectionSavedState:
"""Set up a command's output redirection for >, >>, and |.
@@ -2891,7 +3014,7 @@ def onecmd(self, statement: Union[Statement, str], *, add_to_history: bool = Tru
"""
# For backwards compatibility with cmd, allow a str to be passed in
if not isinstance(statement, Statement):
- statement = self._complete_statement(statement)
+ statement = self._input_line_to_statement(statement)
func = self.cmd_func(statement.command)
if func:
@@ -3224,6 +3347,10 @@ def _build_alias_parser() -> Cmd2ArgumentParser:
"An alias is a command that enables replacement of a word by another string.",
)
alias_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=alias_description)
+ alias_parser.epilog = alias_parser.create_text_group(
+ "See Also",
+ "macro",
+ )
alias_parser.add_subparsers(metavar='SUBCOMMAND', required=True)
return alias_parser
@@ -3273,7 +3400,7 @@ def _build_alias_create_parser(cls) -> Cmd2ArgumentParser:
alias_create_parser.add_argument(
'command',
help='what the alias resolves to',
- choices_provider=cls._get_commands_and_aliases_for_completion,
+ choices_provider=cls._get_commands_aliases_and_macros_for_completion,
)
alias_create_parser.add_argument(
'command_args',
@@ -3299,6 +3426,10 @@ def _alias_create(self, args: argparse.Namespace) -> None:
self.perror("Alias cannot have the same name as a command")
return
+ if args.name in self.macros:
+ self.perror("Alias cannot have the same name as a macro")
+ return
+
# Unquote redirection and terminator tokens
tokens_to_unquote = constants.REDIRECTION_TOKENS
tokens_to_unquote.extend(self.statement_parser.terminators)
@@ -3407,6 +3538,285 @@ def _alias_list(self, args: argparse.Namespace) -> None:
for name in not_found:
self.perror(f"Alias '{name}' not found")
+ #############################################################
+ # Parsers and functions for macro command and subcommands
+ #############################################################
+
+ def macro_arg_complete(
+ self,
+ text: str,
+ line: str,
+ begidx: int,
+ endidx: int,
+ ) -> list[str]:
+ """Tab completes arguments to a macro.
+
+ Its default behavior is to call path_complete, but you can override this as needed.
+
+ The args required by this function are defined in the header of Python's cmd.py.
+
+ :param text: the string prefix we are attempting to match (all matches must begin with it)
+ :param line: the current input line with leading whitespace removed
+ :param begidx: the beginning index of the prefix text
+ :param endidx: the ending index of the prefix text
+ :return: a list of possible tab completions
+ """
+ return self.path_complete(text, line, begidx, endidx)
+
+ # Top-level parser for macro
+ @staticmethod
+ def _build_macro_parser() -> Cmd2ArgumentParser:
+ macro_description = Group(
+ "Manage macros.",
+ "\n",
+ "A macro is similar to an alias, but it can contain argument placeholders.",
+ )
+ macro_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_description)
+ macro_parser.epilog = macro_parser.create_text_group(
+ "See Also",
+ "alias",
+ )
+ macro_parser.add_subparsers(metavar='SUBCOMMAND', required=True)
+
+ return macro_parser
+
+ # Preserve quotes since we are passing strings to other commands
+ @with_argparser(_build_macro_parser, preserve_quotes=True)
+ def do_macro(self, args: argparse.Namespace) -> None:
+ """Manage macros."""
+ # Call handler for whatever subcommand was selected
+ handler = args.cmd2_handler.get()
+ handler(args)
+
+ # macro -> create
+ @classmethod
+ def _build_macro_create_parser(cls) -> Cmd2ArgumentParser:
+ macro_create_description = Group(
+ "Create or overwrite a macro.",
+ "\n",
+ "A macro is similar to an alias, but it can contain argument placeholders.",
+ "\n",
+ "Arguments are expressed when creating a macro using {#} notation where {1} means the first argument.",
+ "\n",
+ "The following creates a macro called my_macro that expects two arguments:",
+ "\n",
+ " macro create my_macro make_dinner --meat {1} --veggie {2}",
+ "\n",
+ "When the macro is called, the provided arguments are resolved and the assembled command is run. For example:",
+ "\n",
+ " my_macro beef broccoli ---> make_dinner --meat beef --veggie broccoli",
+ )
+ macro_create_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_create_description)
+
+ # Create Notes TextGroup
+ macro_create_notes = Group(
+ "To use the literal string {1} in your command, escape it this way: {{1}}.",
+ "\n",
+ "Extra arguments passed to a macro are appended to resolved command.",
+ "\n",
+ (
+ "An argument number can be repeated in a macro. In the following example the "
+ "first argument will populate both {1} instances."
+ ),
+ "\n",
+ " macro create ft file_taxes -p {1} -q {2} -r {1}",
+ "\n",
+ "To quote an argument in the resolved command, quote it during creation.",
+ "\n",
+ " macro create backup !cp \"{1}\" \"{1}.orig\"",
+ "\n",
+ "If you want to use redirection, pipes, or terminators in the value of the macro, then quote them.",
+ "\n",
+ " macro create show_results print_results -type {1} \"|\" less",
+ "\n",
+ (
+ "Since macros don't resolve until after you press Enter, their arguments tab complete as paths. "
+ "This default behavior changes if custom tab completion for macro arguments has been implemented."
+ ),
+ )
+ macro_create_parser.epilog = macro_create_parser.create_text_group("Notes", macro_create_notes)
+
+ # Add arguments
+ macro_create_parser.add_argument('name', help='name of this macro')
+ macro_create_parser.add_argument(
+ 'command',
+ help='what the macro resolves to',
+ choices_provider=cls._get_commands_aliases_and_macros_for_completion,
+ )
+ macro_create_parser.add_argument(
+ 'command_args',
+ nargs=argparse.REMAINDER,
+ help='arguments to pass to command',
+ completer=cls.path_complete,
+ )
+
+ return macro_create_parser
+
+ @as_subcommand_to('macro', 'create', _build_macro_create_parser, help="create or overwrite a macro")
+ def _macro_create(self, args: argparse.Namespace) -> None:
+ """Create or overwrite a macro."""
+ self.last_result = False
+
+ # Validate the macro name
+ valid, errmsg = self.statement_parser.is_valid_command(args.name)
+ if not valid:
+ self.perror(f"Invalid macro name: {errmsg}")
+ return
+
+ if args.name in self.get_all_commands():
+ self.perror("Macro cannot have the same name as a command")
+ return
+
+ if args.name in self.aliases:
+ self.perror("Macro cannot have the same name as an alias")
+ return
+
+ # Unquote redirection and terminator tokens
+ tokens_to_unquote = constants.REDIRECTION_TOKENS
+ tokens_to_unquote.extend(self.statement_parser.terminators)
+ utils.unquote_specific_tokens(args.command_args, tokens_to_unquote)
+
+ # Build the macro value string
+ value = args.command
+ if args.command_args:
+ value += ' ' + ' '.join(args.command_args)
+
+ # Find all normal arguments
+ arg_list = []
+ normal_matches = re.finditer(MacroArg.macro_normal_arg_pattern, value)
+ max_arg_num = 0
+ arg_nums = set()
+
+ try:
+ while True:
+ cur_match = normal_matches.__next__()
+
+ # Get the number string between the braces
+ cur_num_str = re.findall(MacroArg.digit_pattern, cur_match.group())[0]
+ cur_num = int(cur_num_str)
+ if cur_num < 1:
+ self.perror("Argument numbers must be greater than 0")
+ return
+
+ arg_nums.add(cur_num)
+ max_arg_num = max(max_arg_num, cur_num)
+
+ arg_list.append(MacroArg(start_index=cur_match.start(), number_str=cur_num_str, is_escaped=False))
+ except StopIteration:
+ pass
+
+ # Make sure the argument numbers are continuous
+ if len(arg_nums) != max_arg_num:
+ self.perror(f"Not all numbers between 1 and {max_arg_num} are present in the argument placeholders")
+ return
+
+ # Find all escaped arguments
+ escaped_matches = re.finditer(MacroArg.macro_escaped_arg_pattern, value)
+
+ try:
+ while True:
+ cur_match = escaped_matches.__next__()
+
+ # Get the number string between the braces
+ cur_num_str = re.findall(MacroArg.digit_pattern, cur_match.group())[0]
+
+ arg_list.append(MacroArg(start_index=cur_match.start(), number_str=cur_num_str, is_escaped=True))
+ except StopIteration:
+ pass
+
+ # Set the macro
+ result = "overwritten" if args.name in self.macros else "created"
+ self.poutput(f"Macro '{args.name}' {result}")
+
+ self.macros[args.name] = Macro(name=args.name, value=value, minimum_arg_count=max_arg_num, arg_list=arg_list)
+ self.last_result = True
+
+ # macro -> delete
+ @classmethod
+ def _build_macro_delete_parser(cls) -> Cmd2ArgumentParser:
+ macro_delete_description = "Delete specified macros or all macros if --all is used."
+
+ macro_delete_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_delete_description)
+ macro_delete_parser.add_argument('-a', '--all', action='store_true', help="delete all macros")
+ macro_delete_parser.add_argument(
+ 'names',
+ nargs=argparse.ZERO_OR_MORE,
+ help='macro(s) to delete',
+ choices_provider=cls._get_macro_completion_items,
+ descriptive_header=cls._macro_completion_table.generate_header(),
+ )
+
+ return macro_delete_parser
+
+ @as_subcommand_to('macro', 'delete', _build_macro_delete_parser, help="delete macros")
+ def _macro_delete(self, args: argparse.Namespace) -> None:
+ """Delete macros."""
+ self.last_result = True
+
+ if args.all:
+ self.macros.clear()
+ self.poutput("All macros deleted")
+ elif not args.names:
+ self.perror("Either --all or macro name(s) must be specified")
+ self.last_result = False
+ else:
+ for cur_name in utils.remove_duplicates(args.names):
+ if cur_name in self.macros:
+ del self.macros[cur_name]
+ self.poutput(f"Macro '{cur_name}' deleted")
+ else:
+ self.perror(f"Macro '{cur_name}' does not exist")
+
+ # macro -> list
+ macro_list_help = "list macros"
+ macro_list_description = (
+ "List specified macros in a reusable form that can be saved to a startup script\n"
+ "to preserve macros across sessions\n"
+ "\n"
+ "Without arguments, all macros will be listed."
+ )
+
+ macro_list_parser = argparse_custom.DEFAULT_ARGUMENT_PARSER(description=macro_list_description)
+ macro_list_parser.add_argument(
+ 'names',
+ nargs=argparse.ZERO_OR_MORE,
+ help='macro(s) to list',
+ choices_provider=_get_macro_completion_items,
+ descriptive_header=_macro_completion_table.generate_header(),
+ )
+
+ @as_subcommand_to('macro', 'list', macro_list_parser, help=macro_list_help)
+ def _macro_list(self, args: argparse.Namespace) -> None:
+ """List some or all macros as 'macro create' commands."""
+ self.last_result = {} # dict[macro_name, macro_value]
+
+ tokens_to_quote = constants.REDIRECTION_TOKENS
+ tokens_to_quote.extend(self.statement_parser.terminators)
+
+ to_list = utils.remove_duplicates(args.names) if args.names else sorted(self.macros, key=self.default_sort_key)
+
+ not_found: list[str] = []
+ for name in to_list:
+ if name not in self.macros:
+ not_found.append(name)
+ continue
+
+ # Quote redirection and terminator tokens for the 'macro create' command
+ tokens = shlex_split(self.macros[name].value)
+ command = tokens[0]
+ command_args = tokens[1:]
+ utils.quote_specific_tokens(command_args, tokens_to_quote)
+
+ val = command
+ if command_args:
+ val += ' ' + ' '.join(command_args)
+
+ self.poutput(f"macro create {name} {val}")
+ self.last_result[name] = val
+
+ for name in not_found:
+ self.perror(f"Macro '{name}' not found")
+
def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) -> list[str]:
"""Completes the command argument of help."""
# Complete token against topics and visible commands
@@ -4386,7 +4796,7 @@ def _build_history_parser(cls) -> Cmd2ArgumentParser:
'-x',
'--expanded',
action='store_true',
- help='output fully parsed commands with aliases and shortcuts expanded',
+ help='output fully parsed commands with shortcuts, aliases, and macros expanded',
)
history_format_group.add_argument(
'-v',
diff --git a/cmd2/parsing.py b/cmd2/parsing.py
index e488aad1..e12f799c 100644
--- a/cmd2/parsing.py
+++ b/cmd2/parsing.py
@@ -35,6 +35,56 @@ def shlex_split(str_to_split: str) -> list[str]:
return shlex.split(str_to_split, comments=False, posix=False)
+@dataclass(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: int
+
+ # The number string that appears between the braces
+ # This is a string instead of an int because we support unicode digits and must be able
+ # to reproduce this string later
+ number_str: str
+
+ # Tells if this argument is escaped and therefore needs to be unescaped
+ is_escaped: bool
+
+ # Pattern used to find normal argument
+ # Digits surrounded by exactly 1 brace on a side and 1 or more braces on the opposite side
+ # Match strings like: {5}, {{{{{4}, {2}}}}}
+ macro_normal_arg_pattern = re.compile(r'(? str:
def argv(self) -> list[str]:
"""A list of arguments a-la ``sys.argv``.
- The first element of the list is the command after shortcut expansion.
- Subsequent elements of the list contain any additional arguments,
- with quotes removed, just like bash would. This is very useful if
- you are going to use ``argparse.parse_args()``.
+ The first element of the list is the command after shortcut and macro
+ expansion. Subsequent elements of the list contain any additional
+ arguments, with quotes removed, just like bash would. This is very
+ useful if you are going to use ``argparse.parse_args()``.
If you want to strip quotes from the input, you can use ``argv[1:]``.
"""
diff --git a/docs/examples/first_app.md b/docs/examples/first_app.md
index 64e1c1c0..86efd70f 100644
--- a/docs/examples/first_app.md
+++ b/docs/examples/first_app.md
@@ -7,7 +7,7 @@ Here's a quick walkthrough of a simple application which demonstrates 8 features
- [Argument Processing](../features/argument_processing.md)
- [Generating Output](../features/generating_output.md)
- [Help](../features/help.md)
-- [Shortcuts](../features/shortcuts_aliases.md#shortcuts)
+- [Shortcuts](../features/shortcuts_aliases_macros.md#shortcuts)
- [Multiline Commands](../features/multiline_commands.md)
- [History](../features/history.md)
@@ -166,9 +166,10 @@ With those few lines of code, we created a [command](../features/commands.md), u
## Shortcuts
`cmd2` has several capabilities to simplify repetitive user input:
-[Shortcuts and Aliases](../features/shortcuts_aliases.md). Let's add a shortcut to our application.
-Shortcuts are character strings that can be used instead of a command name. For example, `cmd2` has
-support for a shortcut `!` which runs the `shell` command. So instead of typing this:
+[Shortcuts, Aliases, and Macros](../features/shortcuts_aliases_macros.md). Let's add a shortcut to
+our application. Shortcuts are character strings that can be used instead of a command name. For
+example, `cmd2` has support for a shortcut `!` which runs the `shell` command. So instead of typing
+this:
```shell
(Cmd) shell ls -al
diff --git a/docs/features/builtin_commands.md b/docs/features/builtin_commands.md
index 42822a53..ed0e2479 100644
--- a/docs/features/builtin_commands.md
+++ b/docs/features/builtin_commands.md
@@ -9,7 +9,7 @@ to be part of the application.
### alias
This command manages aliases via subcommands `create`, `delete`, and `list`. See
-[Aliases](shortcuts_aliases.md#aliases) for more information.
+[Aliases](shortcuts_aliases_macros.md#aliases) for more information.
### edit
@@ -38,6 +38,12 @@ history. See [History](history.md) for more information.
This optional opt-in command enters an interactive IPython shell. See
[IPython (optional)](./embedded_python_shells.md#ipython-optional) for more information.
+### macro
+
+This command manages macros via subcommands `create`, `delete`, and `list`. A macro is similar to an
+alias, but it can contain argument placeholders. See [Macros](./shortcuts_aliases_macros.md#macros)
+for more information.
+
### py
This command invokes a Python command or shell. See
@@ -108,8 +114,8 @@ Execute a command as if at the operating system shell prompt:
### shortcuts
-This command lists available shortcuts. See [Shortcuts](./shortcuts_aliases.md#shortcuts) for more
-information.
+This command lists available shortcuts. See [Shortcuts](./shortcuts_aliases_macros.md#shortcuts) for
+more information.
## Remove Builtin Commands
diff --git a/docs/features/commands.md b/docs/features/commands.md
index 2693add3..06f3877b 100644
--- a/docs/features/commands.md
+++ b/docs/features/commands.md
@@ -61,7 +61,7 @@ backwards compatibility.
- quoted arguments
- output redirection and piping
- multi-line commands
-- shortcut and alias expansion
+- shortcut, alias, and macro expansion
In addition to parsing all of these elements from the user input, `cmd2` also has code to make all
of these items work; it's almost transparent to you and to the commands you write in your own
diff --git a/docs/features/help.md b/docs/features/help.md
index 41ce44f4..816acc11 100644
--- a/docs/features/help.md
+++ b/docs/features/help.md
@@ -14,8 +14,8 @@ command. The `help` command by itself displays a list of the commands available:
Documented commands (use 'help -v' for verbose/'help ' for details):
===========================================================================
-alias help ipy quit run_script shell
-edit history py run_pyscript set shortcuts
+alias help ipy py run_pyscript set shortcuts
+edit history macro quit run_script shell
```
The `help` command can also be used to provide detailed help for a specific command:
@@ -53,8 +53,8 @@ By default, the `help` command displays:
Documented commands (use 'help -v' for verbose/'help ' for details):
===========================================================================
- alias help ipy quit run_script shell
- edit history py run_pyscript set shortcuts
+ alias help ipy py run_pyscript set shortcuts
+ edit history macro quit run_script shell
If you have a large number of commands, you can optionally group your commands into categories.
Here's the output from the example `help_categories.py`:
@@ -80,8 +80,8 @@ Here's the output from the example `help_categories.py`:
Other
=====
- alias edit history run_pyscript set shortcuts
- config help quit run_script shell version
+ alias edit history py run_pyscript set shortcuts
+ config help macro quit run_script shell version
There are 2 methods of specifying command categories, using the `@with_category` decorator or with
the `categorize()` function. Once a single command category is detected, the help output switches to
@@ -143,9 +143,9 @@ categories with per-command Help Messages:
findleakers Find Leakers command.
list List command.
redeploy Redeploy command.
- restart Restart
+ restart Restart command.
sessions Sessions command.
- start Start
+ start Start command.
stop Stop command.
undeploy Undeploy command.
@@ -164,9 +164,9 @@ categories with per-command Help Messages:
resources Resources command.
serverinfo Server Info command.
sslconnectorciphers SSL Connector Ciphers command is an example of a command that contains
- multiple lines of help information for the user. Each line of help in a
- contiguous set of lines will be printed and aligned in the verbose output
- provided with 'help --verbose'.
+ multiple lines of help information for the user. Each line of help in a
+ contiguous set of lines will be printed and aligned in the verbose output
+ provided with 'help --verbose'.
status Status command.
thread_dump Thread Dump command.
vminfo VM Info command.
@@ -178,6 +178,7 @@ categories with per-command Help Messages:
edit Run a text editor and optionally open a file with it.
help List available commands or provide detailed help for a specific command.
history View, run, edit, save, or clear previously entered commands.
+ macro Manage macros.
quit Exit this application.
run_pyscript Run Python script within this application's environment.
run_script Run text script.
diff --git a/docs/features/history.md b/docs/features/history.md
index bc98dcf4..fdc7c9b4 100644
--- a/docs/features/history.md
+++ b/docs/features/history.md
@@ -198,8 +198,8 @@ without line numbers, so you can copy them to the clipboard:
(Cmd) history -s 1:3
-`cmd2` supports aliases which allow you to substitute a short, more convenient input string with a
-longer replacement string. Say we create an alias like this, and then use it:
+`cmd2` supports both aliases and macros, which allow you to substitute a short, more convenient
+input string with a longer replacement string. Say we create an alias like this, and then use it:
(Cmd) alias create ls shell ls -aF
Alias 'ls' created
@@ -212,7 +212,7 @@ By default, the `history` command shows exactly what we typed:
1 alias create ls shell ls -aF
2 ls -d h*
-There are two ways to modify the display so you can see what aliases and shortcuts were expanded to.
+There are two ways to modify the display so you can see what aliases and macros were expanded to.
The first is to use `-x` or `--expanded`. These options show the expanded command instead of the
entered command:
@@ -229,5 +229,5 @@ option:
2x shell ls -aF -d h*
If the entered command had no expansion, it is displayed as usual. However, if there is some change
-as the result of expanding aliases, then the entered command is displayed with the number, and the
-expanded command is displayed with the number followed by an `x`.
+as the result of expanding macros and aliases, then the entered command is displayed with the
+number, and the expanded command is displayed with the number followed by an `x`.
diff --git a/docs/features/index.md b/docs/features/index.md
index 13ea9afe..13f99715 100644
--- a/docs/features/index.md
+++ b/docs/features/index.md
@@ -24,7 +24,7 @@
- [Output Redirection and Pipes](redirection.md)
- [Scripting](scripting.md)
- [Settings](settings.md)
-- [Shortcuts and Aliases](shortcuts_aliases.md)
+- [Shortcuts, Aliases, and Macros](shortcuts_aliases_macros.md)
- [Startup Commands](startup_commands.md)
- [Table Creation](table_creation.md)
- [Transcripts](transcripts.md)
diff --git a/docs/features/initialization.md b/docs/features/initialization.md
index 85735b87..478a2eb2 100644
--- a/docs/features/initialization.md
+++ b/docs/features/initialization.md
@@ -112,6 +112,7 @@ Here are instance attributes of `cmd2.Cmd` which developers might wish to overri
- **help_error**: the error that prints when no help information can be found
- **hidden_commands**: commands to exclude from the help menu and tab completion
- **last_result**: stores results from the last command run to enable usage of results in a Python script or interactive console. Built-in commands don't make use of this. It is purely there for user-defined commands and convenience.
+- **macros**: dictionary of macro names and their values
- **max_completion_items**: max number of CompletionItems to display during tab completion (Default: 50)
- **pager**: sets the pager command used by the `Cmd.ppaged()` method for displaying wrapped output using a pager
- **pager_chop**: sets the pager command used by the `Cmd.ppaged()` method for displaying chopped/truncated output using a pager
diff --git a/docs/features/os.md b/docs/features/os.md
index 83ffe6de..d1da31bf 100644
--- a/docs/features/os.md
+++ b/docs/features/os.md
@@ -10,8 +10,8 @@ See [Output Redirection and Pipes](./redirection.md#output-redirection-and-pipes
(Cmd) shell ls -al
-If you use the default [Shortcuts](./shortcuts_aliases.md#shortcuts) defined in `cmd2` you'll get a
-`!` shortcut for `shell`, which allows you to type:
+If you use the default [Shortcuts](./shortcuts_aliases_macros.md#shortcuts) defined in `cmd2` you'll
+get a `!` shortcut for `shell`, which allows you to type:
(Cmd) !ls -al
@@ -89,8 +89,8 @@ shell, and execute those commands before entering the command loop:
Documented commands (use 'help -v' for verbose/'help ' for details):
===========================================================================
- alias help ipy quit run_script shell
- edit history py run_pyscript set shortcuts
+ alias help macro orate quit run_script set shortcuts
+ edit history mumble py run_pyscript say shell speak
(Cmd)
diff --git a/docs/features/shortcuts_aliases.md b/docs/features/shortcuts_aliases_macros.md
similarity index 55%
rename from docs/features/shortcuts_aliases.md
rename to docs/features/shortcuts_aliases_macros.md
index 17642ace..b286bc48 100644
--- a/docs/features/shortcuts_aliases.md
+++ b/docs/features/shortcuts_aliases_macros.md
@@ -1,4 +1,4 @@
-# Shortcuts and Aliases
+# Shortcuts, Aliases, and Macros
## Shortcuts
@@ -26,7 +26,7 @@ class App(Cmd):
Shortcuts need to be created by updating the `shortcuts` dictionary attribute prior to calling the `cmd2.Cmd` super class `__init__()` method. Moreover, that super class init method needs to be called after updating the `shortcuts` attribute This warning applies in general to many other attributes which are not settable at runtime.
-Note: Command and alias names cannot start with a shortcut
+Note: Command, alias, and macro names cannot start with a shortcut
## Aliases
@@ -57,4 +57,41 @@ Use `alias delete` to remove aliases
For more details run: `help alias delete`
-Note: Aliases cannot have the same name as a command
+Note: Aliases cannot have the same name as a command or macro
+
+## Macros
+
+`cmd2` provides a feature that is similar to aliases called macros. The major difference between
+macros and aliases is that macros can contain argument placeholders. Arguments are expressed when
+creating a macro using {#} notation where {1} means the first argument.
+
+The following creates a macro called my[macro]{#macro} that expects two arguments:
+
+ macro create my[macro]{#macro} make[dinner]{#dinner} -meat {1} -veggie {2}
+
+When the macro is called, the provided arguments are resolved and the assembled command is run. For
+example:
+
+ my[macro]{#macro} beef broccoli ---> make[dinner]{#dinner} -meat beef -veggie broccoli
+
+Similar to aliases, pipes and redirectors need to be quoted in the definition of a macro:
+
+ macro create lc !cat "{1}" "|" less
+
+To use the literal string `{1}` in your command, escape it this way: `{{1}}`.
+
+Since macros don't resolve until after you press ``, their arguments tab complete as paths.
+You can change this default behavior by overriding `Cmd.macro_arg_complete()` to implement custom
+tab completion for macro arguments.
+
+For more details run: `help macro create`
+
+The macro command has `list` and `delete` subcommands that function identically to the alias
+subcommands of the same name. Like aliases, macros can be created via a `cmd2` startup script to
+preserve them across application sessions.
+
+For more details on listing macros run: `help macro list`
+
+For more details on deleting macros run: `help macro delete`
+
+Note: Macros cannot have the same name as a command or alias
diff --git a/docs/migrating/incompatibilities.md b/docs/migrating/incompatibilities.md
index 1b5e3f99..030959d1 100644
--- a/docs/migrating/incompatibilities.md
+++ b/docs/migrating/incompatibilities.md
@@ -28,7 +28,7 @@ and arguments on whitespace. We opted for this breaking change because while
characters in command names while simultaneously using `identchars` functionality can be somewhat
painful. Requiring white space to delimit arguments also ensures reliable operation of many other
useful `cmd2` features, including [Tab Completion](../features/completion.md) and
-[Shortcuts and Aliases](../features/shortcuts_aliases.md).
+[Shortcuts, Aliases, and Macros](../features/shortcuts_aliases_macros.md).
If you really need this functionality in your app, you can add it back in by writing a
[Postparsing Hook](../features/hooks.md#postparsing-hooks).
diff --git a/docs/migrating/why.md b/docs/migrating/why.md
index 44fbe2a8..060ef0c0 100644
--- a/docs/migrating/why.md
+++ b/docs/migrating/why.md
@@ -37,8 +37,8 @@ and capabilities, without you having to do anything:
Before you do, you might consider the various ways `cmd2` has of
[Generatoring Output](../features/generating_output.md).
- Users can load script files, which contain a series of commands to be executed.
-- Users can create [Shortcuts and Aliases](../features/shortcuts_aliases.md) to reduce the typing
- required for repetitive commands.
+- Users can create [Shortcuts, Aliases, and Macros](../features/shortcuts_aliases_macros.md) to
+ reduce the typing required for repetitive commands.
- Embedded python shell allows a user to execute python code from within your `cmd2` app. How meta.
- [Clipboard Integration](../features/clipboard.md) allows you to save command output to the
operating system clipboard.
diff --git a/mkdocs.yml b/mkdocs.yml
index a8090308..77a3d3d7 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -183,7 +183,7 @@ nav:
- features/redirection.md
- features/scripting.md
- features/settings.md
- - features/shortcuts_aliases.md
+ - features/shortcuts_aliases_macros.md
- features/startup_commands.md
- features/table_creation.md
- features/transcripts.md
diff --git a/tests/conftest.py b/tests/conftest.py
index 19aedaac..253d9fcd 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -70,7 +70,7 @@ def verify_help_text(
Formatting:
-s, --script output commands in script format, i.e. without command numbers
- -x, --expanded output fully parsed commands with aliases and shortcuts expanded
+ -x, --expanded output fully parsed commands with shortcuts, aliases, and macros expanded
-v, --verbose display history and include expanded commands if they differ from the typed command
-a, --all display all commands, including ones persisted from previous sessions
"""
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index e641f9bd..fa9ee561 100644
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -1618,8 +1618,8 @@ def test_multiline_complete_statement_with_unclosed_quotes(multiline_app) -> Non
assert statement.terminator == ';'
-def test_multiline_complete_statement(multiline_app) -> None:
- # Verify _complete_statement saves the fully entered input line for multiline commands
+def test_multiline_input_line_to_statement(multiline_app) -> None:
+ # Verify _input_line_to_statement saves the fully entered input line for multiline commands
# Mock out the input call so we don't actually wait for a user's response
# on stdin when it looks for more input
@@ -1627,7 +1627,7 @@ def test_multiline_complete_statement(multiline_app) -> None:
builtins.input = m
line = 'orate hi'
- statement = multiline_app._complete_statement(line)
+ statement = multiline_app._input_line_to_statement(line)
assert statement.raw == 'orate hi\nperson\n'
assert statement == 'hi person'
assert statement.command == 'orate'
@@ -2004,7 +2004,8 @@ def test_poutput_ansi_never(outsim_app) -> None:
assert out == expected
-invalid_alias_names = [
+# These are invalid names for aliases and macros
+invalid_command_name = [
'""', # Blank name
constants.COMMENT_CHAR,
'!no_shortcut',
@@ -2030,6 +2031,19 @@ def test_get_alias_completion_items(base_app) -> None:
assert cur_res.description.rstrip() == base_app.aliases[cur_res]
+def test_get_macro_completion_items(base_app) -> None:
+ run_cmd(base_app, 'macro create foo !echo foo')
+ run_cmd(base_app, 'macro create bar !echo bar')
+
+ results = base_app._get_macro_completion_items()
+ assert len(results) == len(base_app.macros)
+
+ for cur_res in results:
+ assert cur_res in base_app.macros
+ # Strip trailing spaces from table output
+ assert cur_res.description.rstrip() == base_app.macros[cur_res].value
+
+
def test_get_settable_completion_items(base_app) -> None:
results = base_app._get_settable_completion_items()
assert len(results) == len(base_app.settables)
@@ -2105,7 +2119,7 @@ def test_alias_create_with_quoted_tokens(base_app) -> None:
assert base_app.last_result[alias_name] == alias_command
-@pytest.mark.parametrize('alias_name', invalid_alias_names)
+@pytest.mark.parametrize('alias_name', invalid_command_name)
def test_alias_create_invalid_name(base_app, alias_name, capsys) -> None:
out, err = run_cmd(base_app, f'alias create {alias_name} help')
assert "Invalid alias name" in err[0]
@@ -2118,6 +2132,14 @@ def test_alias_create_with_command_name(base_app) -> None:
assert base_app.last_result is False
+def test_alias_create_with_macro_name(base_app) -> None:
+ macro = "my_macro"
+ run_cmd(base_app, f'macro create {macro} help')
+ out, err = run_cmd(base_app, f'alias create {macro} help')
+ assert "Alias cannot have the same name as a macro" in err[0]
+ assert base_app.last_result is False
+
+
def test_alias_that_resolves_into_comment(base_app) -> None:
# Create the alias
out, err = run_cmd(base_app, 'alias create fake ' + constants.COMMENT_CHAR + ' blah blah')
@@ -2176,6 +2198,228 @@ def test_multiple_aliases(base_app) -> None:
verify_help_text(base_app, out)
+def test_macro_no_subcommand(base_app) -> None:
+ out, err = run_cmd(base_app, 'macro')
+ assert "Usage: macro [-h]" in err[0]
+ assert "Error: the following arguments are required: SUBCOMMAND" in err[1]
+
+
+def test_macro_create(base_app) -> None:
+ # Create the macro
+ out, err = run_cmd(base_app, 'macro create fake run_pyscript')
+ assert out == normalize("Macro 'fake' created")
+ assert base_app.last_result is True
+
+ # Use the macro
+ out, err = run_cmd(base_app, 'fake')
+ assert "the following arguments are required: script_path" in err[1]
+
+ # See a list of macros
+ out, err = run_cmd(base_app, 'macro list')
+ assert out == normalize('macro create fake run_pyscript')
+ assert len(base_app.last_result) == len(base_app.macros)
+ assert base_app.last_result['fake'] == "run_pyscript"
+
+ # Look up the new macro
+ out, err = run_cmd(base_app, 'macro list fake')
+ assert out == normalize('macro create fake run_pyscript')
+ assert len(base_app.last_result) == 1
+ assert base_app.last_result['fake'] == "run_pyscript"
+
+ # Overwrite macro
+ out, err = run_cmd(base_app, 'macro create fake help')
+ assert out == normalize("Macro 'fake' overwritten")
+ assert base_app.last_result is True
+
+ # Look up the updated macro
+ out, err = run_cmd(base_app, 'macro list fake')
+ assert out == normalize('macro create fake help')
+ assert len(base_app.last_result) == 1
+ assert base_app.last_result['fake'] == "help"
+
+
+def test_macro_create_with_quoted_tokens(base_app) -> None:
+ """Demonstrate that quotes in macro value will be preserved"""
+ macro_name = "fake"
+ macro_command = 'help ">" "out file.txt" ";"'
+ create_command = f"macro create {macro_name} {macro_command}"
+
+ # Create the macro
+ out, err = run_cmd(base_app, create_command)
+ assert out == normalize("Macro 'fake' created")
+
+ # Look up the new macro and verify all quotes are preserved
+ out, err = run_cmd(base_app, 'macro list fake')
+ assert out == normalize(create_command)
+ assert len(base_app.last_result) == 1
+ assert base_app.last_result[macro_name] == macro_command
+
+
+@pytest.mark.parametrize('macro_name', invalid_command_name)
+def test_macro_create_invalid_name(base_app, macro_name) -> None:
+ out, err = run_cmd(base_app, f'macro create {macro_name} help')
+ assert "Invalid macro name" in err[0]
+ assert base_app.last_result is False
+
+
+def test_macro_create_with_command_name(base_app) -> None:
+ out, err = run_cmd(base_app, 'macro create help stuff')
+ assert "Macro cannot have the same name as a command" in err[0]
+ assert base_app.last_result is False
+
+
+def test_macro_create_with_alias_name(base_app) -> None:
+ macro = "my_macro"
+ run_cmd(base_app, f'alias create {macro} help')
+ out, err = run_cmd(base_app, f'macro create {macro} help')
+ assert "Macro cannot have the same name as an alias" in err[0]
+ assert base_app.last_result is False
+
+
+def test_macro_create_with_args(base_app) -> None:
+ # Create the macro
+ out, err = run_cmd(base_app, 'macro create fake {1} {2}')
+ assert out == normalize("Macro 'fake' created")
+
+ # Run the macro
+ out, err = run_cmd(base_app, 'fake help -v')
+ verify_help_text(base_app, out)
+
+
+def test_macro_create_with_escaped_args(base_app) -> None:
+ # Create the macro
+ out, err = run_cmd(base_app, 'macro create fake help {{1}}')
+ assert out == normalize("Macro 'fake' created")
+
+ # Run the macro
+ out, err = run_cmd(base_app, 'fake')
+ assert err[0].startswith('No help on {1}')
+
+
+def test_macro_usage_with_missing_args(base_app) -> None:
+ # Create the macro
+ out, err = run_cmd(base_app, 'macro create fake help {1} {2}')
+ assert out == normalize("Macro 'fake' created")
+
+ # Run the macro
+ out, err = run_cmd(base_app, 'fake arg1')
+ assert "expects at least 2 arguments" in err[0]
+
+
+def test_macro_usage_with_exta_args(base_app) -> None:
+ # Create the macro
+ out, err = run_cmd(base_app, 'macro create fake help {1}')
+ assert out == normalize("Macro 'fake' created")
+
+ # Run the macro
+ out, err = run_cmd(base_app, 'fake alias create')
+ assert "Usage: alias create" in out[0]
+
+
+def test_macro_create_with_missing_arg_nums(base_app) -> None:
+ # Create the macro
+ out, err = run_cmd(base_app, 'macro create fake help {1} {3}')
+ assert "Not all numbers between 1 and 3" in err[0]
+ assert base_app.last_result is False
+
+
+def test_macro_create_with_invalid_arg_num(base_app) -> None:
+ # Create the macro
+ out, err = run_cmd(base_app, 'macro create fake help {1} {-1} {0}')
+ assert "Argument numbers must be greater than 0" in err[0]
+ assert base_app.last_result is False
+
+
+def test_macro_create_with_unicode_numbered_arg(base_app) -> None:
+ # Create the macro expecting 1 argument
+ out, err = run_cmd(base_app, 'macro create fake help {\N{ARABIC-INDIC DIGIT ONE}}')
+ assert out == normalize("Macro 'fake' created")
+
+ # Run the macro
+ out, err = run_cmd(base_app, 'fake')
+ assert "expects at least 1 argument" in err[0]
+
+
+def test_macro_create_with_missing_unicode_arg_nums(base_app) -> None:
+ out, err = run_cmd(base_app, 'macro create fake help {1} {\N{ARABIC-INDIC DIGIT THREE}}')
+ assert "Not all numbers between 1 and 3" in err[0]
+ assert base_app.last_result is False
+
+
+def test_macro_that_resolves_into_comment(base_app) -> None:
+ # Create the macro
+ out, err = run_cmd(base_app, 'macro create fake {1} blah blah')
+ assert out == normalize("Macro 'fake' created")
+
+ # Use the macro
+ out, err = run_cmd(base_app, 'fake ' + constants.COMMENT_CHAR)
+ assert not out
+ assert not err
+
+
+def test_macro_list_invalid_macro(base_app) -> None:
+ # Look up invalid macro
+ out, err = run_cmd(base_app, 'macro list invalid')
+ assert "Macro 'invalid' not found" in err[0]
+ assert base_app.last_result == {}
+
+
+def test_macro_delete(base_app) -> None:
+ # Create an macro
+ run_cmd(base_app, 'macro create fake run_pyscript')
+
+ # Delete the macro
+ out, err = run_cmd(base_app, 'macro delete fake')
+ assert out == normalize("Macro 'fake' deleted")
+ assert base_app.last_result is True
+
+
+def test_macro_delete_all(base_app) -> None:
+ out, err = run_cmd(base_app, 'macro delete --all')
+ assert out == normalize("All macros deleted")
+ assert base_app.last_result is True
+
+
+def test_macro_delete_non_existing(base_app) -> None:
+ out, err = run_cmd(base_app, 'macro delete fake')
+ assert "Macro 'fake' does not exist" in err[0]
+ assert base_app.last_result is True
+
+
+def test_macro_delete_no_name(base_app) -> None:
+ out, err = run_cmd(base_app, 'macro delete')
+ assert "Either --all or macro name(s)" in err[0]
+ assert base_app.last_result is False
+
+
+def test_multiple_macros(base_app) -> None:
+ macro1 = 'h1'
+ macro2 = 'h2'
+ run_cmd(base_app, f'macro create {macro1} help')
+ run_cmd(base_app, f'macro create {macro2} help -v')
+ out, err = run_cmd(base_app, macro1)
+ verify_help_text(base_app, out)
+
+ out2, err2 = run_cmd(base_app, macro2)
+ verify_help_text(base_app, out2)
+ assert len(out2) > len(out)
+
+
+def test_nonexistent_macro(base_app) -> None:
+ from cmd2.parsing import (
+ StatementParser,
+ )
+
+ exception = None
+
+ try:
+ base_app._resolve_macro(StatementParser().parse('fake'))
+ except KeyError as e:
+ exception = e
+
+ assert exception is not None
+
+
@with_ansi_style(rich_utils.AllowStyle.ALWAYS)
def test_perror_style(base_app, capsys) -> None:
msg = 'testing...'
@@ -2315,6 +2559,7 @@ def test_get_all_commands(base_app) -> None:
'help',
'history',
'ipy',
+ 'macro',
'py',
'quit',
'run_pyscript',
diff --git a/tests/test_completion.py b/tests/test_completion.py
index 4d9ad79d..702d5bd5 100644
--- a/tests/test_completion.py
+++ b/tests/test_completion.py
@@ -24,6 +24,8 @@
from .conftest import (
complete_tester,
+ normalize,
+ run_cmd,
)
# List of strings used with completion functions
@@ -180,6 +182,26 @@ def test_complete_exception(cmd2_app, capsys) -> None:
assert "IndexError" in err
+def test_complete_macro(base_app, request) -> None:
+ # Create the macro
+ out, err = run_cmd(base_app, 'macro create fake run_pyscript {1}')
+ assert out == normalize("Macro 'fake' created")
+
+ # Macros do path completion
+ test_dir = os.path.dirname(request.module.__file__)
+
+ text = os.path.join(test_dir, 's')
+ line = f'fake {text}'
+
+ endidx = len(line)
+ begidx = endidx - len(text)
+
+ expected = [text + 'cript.py', text + 'cript.txt', text + 'cripts' + os.path.sep]
+ first_match = complete_tester(text, line, begidx, endidx, base_app)
+ assert first_match is not None
+ assert base_app.completion_matches == expected
+
+
def test_default_sort_key(cmd2_app) -> None:
text = ''
line = f'test_sort_key {text}'
diff --git a/tests/test_parsing.py b/tests/test_parsing.py
index 969d00d7..711868ca 100644
--- a/tests/test_parsing.py
+++ b/tests/test_parsing.py
@@ -1039,3 +1039,111 @@ def test_is_valid_command_valid(parser) -> None:
valid, errmsg = parser.is_valid_command('!subcmd', is_subcommand=True)
assert valid
assert not errmsg
+
+
+def test_macro_normal_arg_pattern() -> None:
+ # This pattern matches digits surrounded by exactly 1 brace on a side and 1 or more braces on the opposite side
+ from cmd2.parsing import (
+ MacroArg,
+ )
+
+ pattern = MacroArg.macro_normal_arg_pattern
+
+ # Valid strings
+ matches = pattern.findall('{5}')
+ assert matches == ['{5}']
+
+ matches = pattern.findall('{233}')
+ assert matches == ['{233}']
+
+ matches = pattern.findall('{{{{{4}')
+ assert matches == ['{4}']
+
+ matches = pattern.findall('{2}}}}}')
+ assert matches == ['{2}']
+
+ matches = pattern.findall('{3}{4}{5}')
+ assert matches == ['{3}', '{4}', '{5}']
+
+ matches = pattern.findall('{3} {4} {5}')
+ assert matches == ['{3}', '{4}', '{5}']
+
+ matches = pattern.findall('{3} {{{4} {5}}}}')
+ assert matches == ['{3}', '{4}', '{5}']
+
+ matches = pattern.findall('{3} text {4} stuff {5}}}}')
+ assert matches == ['{3}', '{4}', '{5}']
+
+ # Unicode digit
+ matches = pattern.findall('{\N{ARABIC-INDIC DIGIT ONE}}')
+ assert matches == ['{\N{ARABIC-INDIC DIGIT ONE}}']
+
+ # Invalid strings
+ matches = pattern.findall('5')
+ assert not matches
+
+ matches = pattern.findall('{5')
+ assert not matches
+
+ matches = pattern.findall('5}')
+ assert not matches
+
+ matches = pattern.findall('{{5}}')
+ assert not matches
+
+ matches = pattern.findall('{5text}')
+ assert not matches
+
+
+def test_macro_escaped_arg_pattern() -> None:
+ # This pattern matches digits surrounded by 2 or more braces on both sides
+ from cmd2.parsing import (
+ MacroArg,
+ )
+
+ pattern = MacroArg.macro_escaped_arg_pattern
+
+ # Valid strings
+ matches = pattern.findall('{{5}}')
+ assert matches == ['{{5}}']
+
+ matches = pattern.findall('{{233}}')
+ assert matches == ['{{233}}']
+
+ matches = pattern.findall('{{{{{4}}')
+ assert matches == ['{{4}}']
+
+ matches = pattern.findall('{{2}}}}}')
+ assert matches == ['{{2}}']
+
+ matches = pattern.findall('{{3}}{{4}}{{5}}')
+ assert matches == ['{{3}}', '{{4}}', '{{5}}']
+
+ matches = pattern.findall('{{3}} {{4}} {{5}}')
+ assert matches == ['{{3}}', '{{4}}', '{{5}}']
+
+ matches = pattern.findall('{{3}} {{{4}} {{5}}}}')
+ assert matches == ['{{3}}', '{{4}}', '{{5}}']
+
+ matches = pattern.findall('{{3}} text {{4}} stuff {{5}}}}')
+ assert matches == ['{{3}}', '{{4}}', '{{5}}']
+
+ # Unicode digit
+ matches = pattern.findall('{{\N{ARABIC-INDIC DIGIT ONE}}}')
+ assert matches == ['{{\N{ARABIC-INDIC DIGIT ONE}}}']
+
+ # Invalid strings
+ matches = pattern.findall('5')
+ assert not matches
+
+ matches = pattern.findall('{{5')
+ assert not matches
+
+ matches = pattern.findall('5}}')
+ assert not matches
+
+ matches = pattern.findall('{5}')
+ assert not matches
+
+ matches = pattern.findall('{{5text}}')
+ assert not matches
diff --git a/tests_isolated/test_commandset/conftest.py b/tests_isolated/test_commandset/conftest.py
index 6a3d66ab..fe6af8c3 100644
--- a/tests_isolated/test_commandset/conftest.py
+++ b/tests_isolated/test_commandset/conftest.py
@@ -74,7 +74,7 @@ def verify_help_text(
formatting:
-s, --script output commands in script format, i.e. without command
numbers
- -x, --expanded output fully parsed commands with aliases and shortcuts expanded
+ -x, --expanded output fully parsed commands with any shortcuts, aliases, and macros expanded
-v, --verbose display history and include expanded commands if they
differ from the typed command
-a, --all display all commands, including ones persisted from
diff --git a/tests_isolated/test_commandset/test_commandset.py b/tests_isolated/test_commandset/test_commandset.py
index 9ea4eb3b..7498e145 100644
--- a/tests_isolated/test_commandset/test_commandset.py
+++ b/tests_isolated/test_commandset/test_commandset.py
@@ -343,17 +343,17 @@ def test_load_commandset_errors(command_sets_manual, capsys) -> None:
delattr(command_sets_manual, 'do_durian')
- # pre-create aliases with names which conflict with commands
- command_sets_manual.app_cmd('alias create apple run_pyscript')
+ # pre-create intentionally conflicting macro and alias names
+ command_sets_manual.app_cmd('macro create apple run_pyscript')
command_sets_manual.app_cmd('alias create banana run_pyscript')
# now install a command set and verify the commands are now present
command_sets_manual.register_command_set(cmd_set)
out, err = capsys.readouterr()
- # verify aliases are deleted with warning if they conflict with a command
- assert "Deleting alias 'apple'" in err
+ # verify aliases and macros are deleted with warning if they conflict with a command
assert "Deleting alias 'banana'" in err
+ assert "Deleting macro 'apple'" in err
# verify command functions which don't start with "do_" raise an exception
with pytest.raises(CommandSetRegistrationError):