summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKevin Van Brunt <kmvanbrunt@gmail.com>2018-09-26 13:12:31 -0400
committerKevin Van Brunt <kmvanbrunt@gmail.com>2018-09-26 13:12:31 -0400
commitee9c10115380635da070df05e83d9fd175187fb8 (patch)
tree12d06617e998c737003011df34ce7c909fa34d2b
parente3d1bd12c2ecea2284d5af60cb5e903277ecb42c (diff)
downloadcmd2-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.py76
-rw-r--r--cmd2/parsing.py13
-rw-r--r--tests/test_cmd2.py23
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