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):