summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--cmd2/cmd2.py216
-rw-r--r--cmd2/parsing.py44
-rw-r--r--cmd2/utils.py6
3 files changed, 237 insertions, 29 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 57660331..1410b00a 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -47,7 +47,7 @@ from . import utils
from . import plugin
from .argparse_completer import AutoCompleter, ACArgumentParser, ACTION_ARG_CHOICES
from .clipboard import can_clip, get_paste_buffer, write_to_paste_buffer
-from .parsing import StatementParser, Statement
+from .parsing import StatementParser, Statement, Macro, normal_arg_pattern, escaped_arg_pattern, digit_pattern
# Set up readline
from .rl_utils import rl_type, RlType
@@ -307,6 +307,7 @@ class Cmd(cmd.Cmd):
multiline_commands = []
shortcuts = {'?': 'help', '!': 'shell', '@': 'load', '@@': '_relative_load'}
aliases = dict()
+ macros = dict()
terminators = [';']
# Attributes which are NOT dynamically settable at runtime
@@ -1510,11 +1511,9 @@ class Cmd(cmd.Cmd):
[shortcut_to_restore + match for match in self.completion_matches]
else:
- # Complete token against aliases and command names
- alias_names = set(self.aliases.keys())
- visible_commands = set(self.get_visible_commands())
- strs_to_match = list(alias_names | visible_commands)
- self.completion_matches = self.basic_complete(text, line, begidx, endidx, strs_to_match)
+ # Complete token against anything a user can run
+ self.completion_matches = self.basic_complete(text, line, begidx, endidx,
+ self._get_all_runnable_names())
# Handle single result
if len(self.completion_matches) == 1:
@@ -1541,6 +1540,21 @@ class Cmd(cmd.Cmd):
except IndexError:
return None
+ def _get_all_runnable_names(self) -> List[str]:
+ """Return a list of all commands, aliases, and macros"""
+ visible_commands = set(self.get_visible_commands())
+ alias_names = set(self._get_alias_names())
+ macro_names = set(self._get_macro_names())
+ return list(visible_commands | alias_names | macro_names)
+
+ def _get_alias_names(self) -> List[str]:
+ """Returns a list of all alias names"""
+ return list(self.aliases)
+
+ def _get_macro_names(self) -> List[str]:
+ """Returns a list of all macro names"""
+ return list(self.macros)
+
def _autocomplete_default(self, text: str, line: str, begidx: int, endidx: int,
argparser: argparse.ArgumentParser) -> List[str]:
"""Default completion function for argparse commands."""
@@ -2228,6 +2242,11 @@ class Cmd(cmd.Cmd):
self.perror(errmsg, traceback_war=False)
return
+ if args.name in self._get_macro_names():
+ errmsg = "Aliases cannot have the same name as a macro"
+ self.perror(errmsg, traceback_war=False)
+ return
+
stripped_command = args.command.strip()
if not stripped_command:
errmsg = "An alias cannot resolve to an empty string"
@@ -2247,7 +2266,7 @@ class Cmd(cmd.Cmd):
self.aliases.clear()
self.poutput("All aliases deleted")
elif not args.name:
- self.onecmd('help alias delete')
+ self.do_help(['alias', 'delete'])
else:
# Get rid of duplicates
aliases_to_delete = utils.remove_duplicates(args.name)
@@ -2269,20 +2288,9 @@ class Cmd(cmd.Cmd):
else:
self.perror("Alias {!r} not found".format(cur_name), traceback_war=False)
else:
- # noinspection PyTypeChecker
- sorted_aliases = utils.alphabetical_sort(self.aliases.keys())
+ sorted_aliases = utils.alphabetical_sort(self.aliases)
for cur_alias in sorted_aliases:
- self.poutput("alias {} {}".format(cur_alias, self.aliases[cur_alias]))
-
- def get_aliases(self):
- """ Used to complete alias names """
- return self.aliases.keys()
-
- def get_aliases_and_commands(self):
- """ Used to complete alias and command names """
- alias_names = set(self.aliases.keys())
- visible_commands = set(self.get_visible_commands())
- return list(alias_names | visible_commands)
+ self.poutput("alias create {} {}".format(cur_alias, self.aliases[cur_alias]))
# Top-level parser for alias
alias_parser = ACArgumentParser(description="Manage aliases", prog='alias')
@@ -2307,9 +2315,9 @@ class Cmd(cmd.Cmd):
description=alias_create_description,
epilog=alias_create_epilog)
setattr(alias_create_parser.add_argument('name', type=str, help='Name of this alias'),
- ACTION_ARG_CHOICES, get_aliases_and_commands)
- setattr(alias_create_parser.add_argument('command', type=str, help='command or alias the alias resolves to'),
- ACTION_ARG_CHOICES, get_aliases_and_commands)
+ ACTION_ARG_CHOICES, _get_all_runnable_names)
+ setattr(alias_create_parser.add_argument('command', type=str, help='what the alias resolves to'),
+ ACTION_ARG_CHOICES, _get_all_runnable_names)
setattr(alias_create_parser.add_argument('command_args', type=str, nargs=argparse.REMAINDER,
help='arguments being passed to command'),
ACTION_ARG_CHOICES, ('path_complete',))
@@ -2321,7 +2329,7 @@ class Cmd(cmd.Cmd):
alias_delete_parser = alias_subparsers.add_parser('delete', help=alias_delete_help,
description=alias_delete_description)
setattr(alias_delete_parser.add_argument('name', type=str, nargs='*', help='alias to delete'),
- ACTION_ARG_CHOICES, get_aliases)
+ ACTION_ARG_CHOICES, _get_alias_names)
alias_delete_parser.add_argument('-a', '--all', action='store_true', help="all aliases will be deleted")
alias_delete_parser.set_defaults(func=alias_delete)
@@ -2335,7 +2343,7 @@ class Cmd(cmd.Cmd):
alias_list_parser = alias_subparsers.add_parser('list', help=alias_list_help,
description=alias_list_description)
setattr(alias_list_parser.add_argument('name', type=str, nargs="*", help='alias to list'),
- ACTION_ARG_CHOICES, get_aliases)
+ ACTION_ARG_CHOICES, _get_alias_names)
alias_list_parser.set_defaults(func=alias_list)
@with_argparser(alias_parser)
@@ -2347,7 +2355,163 @@ class Cmd(cmd.Cmd):
func(self, args)
else:
# No subcommand was provided, so call help
- self.do_help(['alias'])
+ self.alias_parser.print_help()
+
+ # ----- Macro subcommand functions -----
+
+ def macro_create(self, args: argparse.Namespace):
+ """ Creates or overwrites a macro """
+ # Validate the macro name
+ valid, errmsg = self.statement_parser.is_valid_command(args.name)
+ if not valid:
+ errmsg = "Macro names {}".format(errmsg)
+ self.perror(errmsg, traceback_war=False)
+ return
+
+ if args.name in self.get_all_commands():
+ errmsg = "Macros cannot have the same name as a command"
+ self.perror(errmsg, traceback_war=False)
+ return
+
+ if args.name in self._get_alias_names():
+ errmsg = "Macros cannot have the same name as an alias"
+ self.perror(errmsg, traceback_war=False)
+ return
+
+ stripped_command = args.command.strip()
+ if not stripped_command:
+ errmsg = "An macro cannot resolve to an empty string"
+ self.perror(errmsg, traceback_war=False)
+ return
+
+ # Build the macro value string
+ value = stripped_command + ' ' + ' '.join(utils.quote_string_if_needed(args.command_args))
+
+ # Find all normal arguments
+ arg_info_list = []
+ normal_matches = re.finditer(normal_arg_pattern, value)
+ max_arg_num = 0
+ num_set = set()
+
+ while True:
+ try:
+ cur_match = normal_matches.__next__()
+
+ # Get the number between the braces
+ cur_num = int(re.findall(digit_pattern, cur_match.group())[0])
+ num_set.add(cur_num)
+ if cur_num > max_arg_num:
+ max_arg_num = cur_num
+
+ arg_info_list.append(Macro.ArgInfo(start_index=cur_match.start(),
+ number=cur_num,
+ is_escaped=False))
+
+ except StopIteration:
+ break
+
+ # Make sure the argument numbers are continuous
+ if len(num_set) != max_arg_num:
+ self.perror("Not all numbers between 1 and {} are present "
+ "in the argument placeholders".format(max_arg_num), traceback_war=False)
+ return
+
+ # Find all escaped arguments
+ escaped_matches = re.finditer(escaped_arg_pattern, value)
+
+ while True:
+ try:
+ cur_match = escaped_matches.__next__()
+
+ # Get the number between the braces
+ cur_num = int(re.findall(digit_pattern, cur_match.group())[0])
+
+ arg_info_list.append(Macro.ArgInfo(start_index=cur_match.start(),
+ number=cur_num,
+ is_escaped=True))
+ except StopIteration:
+ break
+
+ # Set the macro
+ self.macros[args.name] = Macro(name=args.name, value=value,
+ min_arg_count=max_arg_num, arg_info_list=arg_info_list)
+ self.poutput("Macro {!r} created".format(args.name))
+
+ def macro_list(self, args: argparse.Namespace):
+ """ Lists some or all macros """
+ if args.name:
+ names_to_view = utils.remove_duplicates(args.name)
+ for cur_name in names_to_view:
+ if cur_name in self.macros:
+ self.poutput("macro create {} {}".format(cur_name, self.macros[cur_name].value))
+ else:
+ self.perror("Macro {!r} not found".format(cur_name), traceback_war=False)
+ else:
+ sorted_macros = utils.alphabetical_sort(self.macros)
+ for cur_macro in sorted_macros:
+ self.poutput("macro create {} {}".format(cur_macro, self.macros[cur_macro].value))
+
+ # Top-level parser for macro
+ macro_parser = ACArgumentParser(description="Manage macros", prog='macro')
+
+ # Add subcommands to macro
+ macro_subparsers = macro_parser.add_subparsers()
+
+ # macro -> create
+ macro_create_help = "create or overwrite a macro"
+ macro_create_description = "Create or overwrite an macro\n"
+ macro_create_description += "\n"
+ macro_create_description += "A macro is similar to an alias, but it can take arguments\n"
+ macro_create_description += "A macro is similar to an alias, but it can take arguments"
+
+ macro_create_epilog = "Notes:\n"
+ macro_create_epilog += " Macros can resolve into commands, aliases, and other macros. Therefore it is\n"
+ macro_create_epilog += " possible to create a macro that results in an infinite cycle. Since all use cases\n"
+ macro_create_epilog += " are different, cycles will not be prevented. In other words, be careful.\n"
+ macro_create_epilog += "\n"
+ macro_create_epilog += " If you want to use redirection or pipes in the macro, then quote them to prevent\n"
+ macro_create_epilog += " the macro command itself from being redirected\n"
+ macro_create_epilog += "\n"
+ macro_create_epilog += "Examples:\n"
+ macro_create_epilog += " macro create quick_meal make_fish -type {1} -style {2}\n"
+ macro_create_epilog += " macro create dl delete_log -f {1} --now\n"
+ macro_create_epilog += " macro create save_results print_results -type {1} \">\" {2}\n"
+
+ macro_create_parser = macro_subparsers.add_parser('create', help=macro_create_help,
+ description=macro_create_description,
+ epilog=macro_create_epilog)
+ setattr(macro_create_parser.add_argument('name', type=str, help='Name of this macro'),
+ ACTION_ARG_CHOICES, _get_macro_names)
+ setattr(macro_create_parser.add_argument('command', type=str, help='what the macro resolves to'),
+ ACTION_ARG_CHOICES, _get_all_runnable_names)
+ setattr(macro_create_parser.add_argument('command_args', type=str, nargs=argparse.REMAINDER,
+ help='arguments being passed to command'),
+ ACTION_ARG_CHOICES, ('path_complete',))
+ macro_create_parser.set_defaults(func=macro_create)
+
+ # macro -> list
+ macro_list_help = "list macros"
+ macro_list_description = "List specified macros in a reusable form that can be saved to\n"
+ macro_list_description += "a startup_script to preserve macros across sessions\n"
+ macro_list_description += "\n"
+ macro_list_description += "Without arguments, all macros will be listed"
+
+ macro_list_parser = macro_subparsers.add_parser('list', help=macro_list_help,
+ description=macro_list_description)
+ setattr(macro_list_parser.add_argument('name', type=str, nargs="*", help='macro to list'),
+ ACTION_ARG_CHOICES, _get_macro_names)
+ macro_list_parser.set_defaults(func=macro_list)
+
+ @with_argparser(macro_parser)
+ def do_macro(self, args: argparse.Namespace):
+ """ Manages macros """
+ func = getattr(args, 'func', None)
+ if func is not None:
+ # Call whatever subcommand function was selected
+ func(self, args)
+ else:
+ # No subcommand was provided, so call help
+ self.macro_parser.print_help()
@with_argument_list
def do_help(self, arglist: List[str]) -> None:
diff --git a/cmd2/parsing.py b/cmd2/parsing.py
index c21da920..4d17d07e 100644
--- a/cmd2/parsing.py
+++ b/cmd2/parsing.py
@@ -12,6 +12,50 @@ import attr
from . import constants
from . import utils
+# Pattern used to find normal argument
+# Match strings like: {5}, {{{{{4}, {2}}}}}
+normal_arg_pattern = re.compile(r'(?<!\{)\{\d+\}|\{\d+\}(?!\})')
+
+# Pattern used to find escaped arguments (2 or more braces on each side of digit)
+# Match strings like: {{5}}, {{{{{4}}, {{2}}}}}, {{{4}}}
+escaped_arg_pattern = re.compile(r'\{{2}\d+\}{2}')
+
+# Finds a string of digits
+digit_pattern = re.compile(r'\d+')
+
+
+@attr.s(frozen=True)
+class Macro:
+ """Defines a cmd2 macro"""
+
+ @attr.s(frozen=True)
+ class ArgInfo:
+ """
+ Information used to replace or unescape arguments in a macro value when the macro is resolved
+ Normal argument syntax : {5}
+ Escaped argument syntax: {{5}}
+ """
+ # The starting index of this argument in the macro value
+ start_index = attr.ib(validator=attr.validators.instance_of(int), type=int)
+
+ # The number that appears between the braces
+ number = attr.ib(validator=attr.validators.instance_of(int), type=int)
+
+ # Tells if this argument is escaped and therefore needs to be unescaped
+ is_escaped = attr.ib(validator=attr.validators.instance_of(bool), type=bool)
+
+ # Name of the macro
+ name = attr.ib(validator=attr.validators.instance_of(str), type=str)
+
+ # The string the macro resolves to
+ value = attr.ib(validator=attr.validators.instance_of(str), type=str)
+
+ # The minimum number of args the user has to pass to this macro
+ min_arg_count = attr.ib(validator=attr.validators.instance_of(int), type=int)
+
+ # Used to fill in argument placeholders in the macro
+ arg_info_list = attr.ib(factory=list, validator=attr.validators.instance_of(list), type=List[ArgInfo])
+
@attr.s(frozen=True)
class Statement(str):
diff --git a/cmd2/utils.py b/cmd2/utils.py
index bdb488cc..3527236f 100644
--- a/cmd2/utils.py
+++ b/cmd2/utils.py
@@ -6,7 +6,7 @@ import collections
import os
import re
import unicodedata
-from typing import Any, List, Optional, Union
+from typing import Any, Iterable, List, Optional, Union
from . import constants
@@ -194,7 +194,7 @@ def norm_fold(astr: str) -> str:
return unicodedata.normalize('NFC', astr).casefold()
-def alphabetical_sort(list_to_sort: List[str]) -> List[str]:
+def alphabetical_sort(list_to_sort: Iterable[str]) -> List[str]:
"""Sorts a list of strings alphabetically.
For example: ['a1', 'A11', 'A2', 'a22', 'a3']
@@ -232,7 +232,7 @@ def natural_keys(input_str: str) -> List[Union[int, str]]:
return [try_int_or_force_to_lower_case(substr) for substr in re.split('(\d+)', input_str)]
-def natural_sort(list_to_sort: List[str]) -> List[str]:
+def natural_sort(list_to_sort: Iterable[str]) -> List[str]:
"""
Sorts a list of strings case insensitively as well as numerically.