diff options
author | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2018-09-26 13:12:31 -0400 |
---|---|---|
committer | Kevin Van Brunt <kmvanbrunt@gmail.com> | 2018-09-26 13:12:31 -0400 |
commit | ee9c10115380635da070df05e83d9fd175187fb8 (patch) | |
tree | 12d06617e998c737003011df34ce7c909fa34d2b | |
parent | e3d1bd12c2ecea2284d5af60cb5e903277ecb42c (diff) | |
download | cmd2-git-ee9c10115380635da070df05e83d9fd175187fb8.tar.gz |
Added ability to preserve quotes in argparse and arglist decorated functions to support aliases and macros
-rw-r--r-- | cmd2/cmd2.py | 76 | ||||
-rw-r--r-- | cmd2/parsing.py | 13 | ||||
-rw-r--r-- | tests/test_cmd2.py | 23 |
3 files changed, 71 insertions, 41 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 8fdaa697..47b6cb1c 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -139,19 +139,21 @@ def categorize(func: Union[Callable, Iterable], category: str) -> None: setattr(func, HELP_CATEGORY, category) -def parse_quoted_string(cmdline: str) -> List[str]: - """Parse a quoted string into a list of arguments.""" - if isinstance(cmdline, list): +def parse_quoted_string(string: str, preserve_quotes: bool) -> List[str]: + """ + Parse a quoted string into a list of arguments + :param string: the string being parsed + :param preserve_quotes: if True, then quotes will not be stripped + """ + if isinstance(string, list): # arguments are already a list, return the list we were passed - lexed_arglist = cmdline + lexed_arglist = string else: # Use shlex to split the command line into a list of arguments based on shell rules - lexed_arglist = shlex.split(cmdline, posix=False) - # strip off outer quotes for convenience - temp_arglist = [] - for arg in lexed_arglist: - temp_arglist.append(utils.strip_quotes(arg)) - lexed_arglist = temp_arglist + lexed_arglist = shlex.split(string, posix=False) + + if not preserve_quotes: + lexed_arglist = [utils.strip_quotes(arg) for arg in lexed_arglist] return lexed_arglist @@ -163,7 +165,7 @@ def with_category(category: str) -> Callable: return cat_decorator -def with_argument_list(func: Callable) -> Callable: +def with_argument_list(func: Callable, preserve_quotes: bool=False) -> Callable: """A decorator to alter the arguments passed to a do_* cmd2 method. Default passes a string of whatever the user typed. With this decorator, the decorated method will receive a list @@ -172,18 +174,19 @@ def with_argument_list(func: Callable) -> Callable: @functools.wraps(func) def cmd_wrapper(self, cmdline): - lexed_arglist = parse_quoted_string(cmdline) + lexed_arglist = parse_quoted_string(cmdline, preserve_quotes) return func(self, lexed_arglist) cmd_wrapper.__doc__ = func.__doc__ return cmd_wrapper -def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Callable: +def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser, preserve_quotes: bool=False) -> Callable: """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given instance of argparse.ArgumentParser, but also returning unknown args as a list. :param argparser: given instance of ArgumentParser + :param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes :return: function that gets passed parsed args and a list of unknown args """ import functools @@ -192,7 +195,7 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Calla def arg_decorator(func: Callable): @functools.wraps(func) def cmd_wrapper(instance, cmdline): - lexed_arglist = parse_quoted_string(cmdline) + lexed_arglist = parse_quoted_string(cmdline, preserve_quotes) try: args, unknown = argparser.parse_known_args(lexed_arglist) except SystemExit: @@ -221,11 +224,12 @@ def with_argparser_and_unknown_args(argparser: argparse.ArgumentParser) -> Calla return arg_decorator -def with_argparser(argparser: argparse.ArgumentParser) -> Callable: +def with_argparser(argparser: argparse.ArgumentParser, preserve_quotes: bool=False) -> Callable: """A decorator to alter a cmd2 method to populate its ``args`` argument by parsing arguments with the given instance of argparse.ArgumentParser. :param argparser: given instance of ArgumentParser + :param preserve_quotes: if True, then the arguments passed to arparse be maintain their quotes :return: function that gets passed parsed args """ import functools @@ -234,7 +238,7 @@ def with_argparser(argparser: argparse.ArgumentParser) -> Callable: def arg_decorator(func: Callable): @functools.wraps(func) def cmd_wrapper(instance, cmdline): - lexed_arglist = parse_quoted_string(cmdline) + lexed_arglist = parse_quoted_string(cmdline, preserve_quotes) try: args = argparser.parse_args(lexed_arglist) except SystemExit: @@ -2205,9 +2209,9 @@ class Cmd(cmd.Cmd): def alias_create(self, args: argparse.Namespace): """ Creates or overwrites an alias """ # Validate the alias name - valid, errmsg = self.statement_parser.is_valid_command(args.name) + valid, errmsg = self.statement_parser.is_valid_command(args.name, allow_shortcut=False) if not valid: - errmsg = "Alias names {}".format(errmsg) + errmsg = "Invalid alias name: {}".format(errmsg) self.perror(errmsg, traceback_war=False) return @@ -2216,19 +2220,27 @@ class Cmd(cmd.Cmd): self.perror(errmsg, traceback_war=False) return - if not args.command.strip(): - errmsg = "Aliases cannot resolve to an empty command" + valid, errmsg = self.statement_parser.is_valid_command(args.command, allow_shortcut=True) + if not valid: + errmsg = "Invalid alias target: {}".format(errmsg) self.perror(errmsg, traceback_war=False) return + # Unquote redirection and pipes + for i, arg in enumerate(args.command_args): + unquoted_arg = utils.strip_quotes(arg) + if unquoted_arg in constants.REDIRECTION_TOKENS: + args.command_args[i] = unquoted_arg + # Build the alias value string value = utils.quote_string_if_needed(args.command) for cur_arg in args.command_args: value += ' ' + utils.quote_string_if_needed(cur_arg) # Set the alias + result = "overwritten" if args.name in self.aliases else "created" self.aliases[args.name] = value - self.poutput("Alias {!r} created".format(args.name)) + self.poutput("Alias {!r} {}".format(args.name, result)) def alias_delete(self, args: argparse.Namespace): """ Deletes aliases """ @@ -2324,7 +2336,7 @@ class Cmd(cmd.Cmd): ACTION_ARG_CHOICES, aliases) alias_list_parser.set_defaults(func=alias_list) - @with_argparser(alias_parser) + @with_argparser(alias_parser, preserve_quotes=True) def do_alias(self, args: argparse.Namespace): """Manage aliases""" func = getattr(args, 'func', None) @@ -2340,9 +2352,9 @@ class Cmd(cmd.Cmd): 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) + valid, errmsg = self.statement_parser.is_valid_command(args.name, allow_shortcut=False) if not valid: - errmsg = "Macro names {}".format(errmsg) + errmsg = "Invalid macro name: {}".format(errmsg) self.perror(errmsg, traceback_war=False) return @@ -2356,11 +2368,18 @@ class Cmd(cmd.Cmd): self.perror(errmsg, traceback_war=False) return - if not args.command.strip(): - errmsg = "Macros cannot resolve to an empty command" + valid, errmsg = self.statement_parser.is_valid_command(args.command, allow_shortcut=True) + if not valid: + errmsg = "Invalid macro target: {}".format(errmsg) self.perror(errmsg, traceback_war=False) return + # Unquote redirection and pipes + for i, arg in enumerate(args.command_args): + unquoted_arg = utils.strip_quotes(arg) + if unquoted_arg in constants.REDIRECTION_TOKENS: + args.command_args[i] = unquoted_arg + # Build the macro value string value = utils.quote_string_if_needed(args.command) for cur_arg in args.command_args: @@ -2412,8 +2431,9 @@ class Cmd(cmd.Cmd): break # Set the macro + result = "overwritten" if args.name in self.macros else "created" self.macros[args.name] = Macro(name=args.name, value=value, required_arg_count=max_arg_num, arg_list=arg_list) - self.poutput("Macro {!r} created".format(args.name)) + self.poutput("Macro {!r} {}".format(args.name, result)) def macro_delete(self, args: argparse.Namespace): """ Deletes macros """ @@ -2533,7 +2553,7 @@ class Cmd(cmd.Cmd): ACTION_ARG_CHOICES, macros) macro_list_parser.set_defaults(func=macro_list) - @with_argparser(macro_parser) + @with_argparser(macro_parser, preserve_quotes=True) def do_macro(self, args: argparse.Namespace): """ Manage macros """ func = getattr(args, 'func', None) diff --git a/cmd2/parsing.py b/cmd2/parsing.py index 498834cf..82e8ee39 100644 --- a/cmd2/parsing.py +++ b/cmd2/parsing.py @@ -291,7 +291,7 @@ class StatementParser: expr = r'\A\s*(\S*?)({})'.format(second_group) self._command_pattern = re.compile(expr) - def is_valid_command(self, word: str) -> Tuple[bool, str]: + def is_valid_command(self, word: str, allow_shortcut: bool) -> Tuple[bool, str]: """Determine whether a word is a valid name for a command. Commands can not include redirection characters, whitespace, @@ -311,11 +311,12 @@ class StatementParser: if not word: return False, 'cannot be an empty string' - errmsg = 'cannot start with a shortcut: ' - errmsg += ', '.join(shortcut for (shortcut, expansion) in self.shortcuts) - for (shortcut, expansion) in self.shortcuts: - if word.startswith(shortcut): - return False, errmsg + if not allow_shortcut: + errmsg = 'cannot start with a shortcut: ' + errmsg += ', '.join(shortcut for (shortcut, expansion) in self.shortcuts) + for (shortcut, expansion) in self.shortcuts: + if word.startswith(shortcut): + return False, errmsg errmsg = 'cannot contain: whitespace, quotes, ' errchars = [] diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 05a38457..5523d801 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1765,7 +1765,7 @@ def test_alias_create(base_app, capsys): out = run_cmd(base_app, 'alias list') assert out == normalize('alias create fake pyscript') - # Lookup the new alias + # Look up the new alias out = run_cmd(base_app, 'alias list fake') assert out == normalize('alias create fake pyscript') @@ -1774,7 +1774,7 @@ def test_alias_create_with_quotes(base_app, capsys): out = run_cmd(base_app, 'alias create fake help ">" "out file.txt"') assert out == normalize("Alias 'fake' created") - # Lookup the new alias (Only the redirector should be unquoted) + # Look up the new alias (Only the redirector should be unquoted) out = run_cmd(base_app, 'alias list fake') assert out == normalize('alias create fake help > "out file.txt"') @@ -1791,7 +1791,7 @@ def test_alias_create_with_quotes(base_app, capsys): def test_alias_create_invalid_name(base_app, alias_name, capsys): run_cmd(base_app, 'alias create {} help'.format(alias_name)) out, err = capsys.readouterr() - assert "cannot" in err + assert "Invalid alias name" in err def test_alias_create_with_macro_name(base_app, capsys): macro = "my_macro" @@ -1800,13 +1800,22 @@ def test_alias_create_with_macro_name(base_app, capsys): out, err = capsys.readouterr() assert "cannot have the same name" in err -def test_alias_create_with_empty_command(base_app, capsys): - run_cmd(base_app, 'alias create my_alias ""') +@pytest.mark.parametrize('alias_target', [ + '""', # Blank name + '">"', + '"no>pe"', + '"no spaces"', + '"nopipe|"', + '"noterm;"', + 'noembedded"quotes', +]) +def test_alias_create_with_invalid_command(base_app, alias_target, capsys): + run_cmd(base_app, 'alias create my_alias {}'.format(alias_target)) out, err = capsys.readouterr() - assert "cannot resolve to an empty command" in err + assert "Invalid alias target" in err def test_alias_list_invalid_alias(base_app, capsys): - # Lookup invalid alias + # Look up invalid alias out = run_cmd(base_app, 'alias list invalid') out, err = capsys.readouterr() assert "not found" in err |