diff --git a/CHANGELOG.md b/CHANGELOG.md index d1f339310..e2ada12f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 1.3.11 (TBD, 2020) +* Bug Fixes + * Fixed issue where quoted redirectors and terminators in aliases and macros were not being + restored when read from a startup script. + ## 1.3.10 (September 17, 2020) * Enhancements * Added user-settable option called `always_show_hint`. If True, then tab completion hints will always diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index d1a26a2b4..8810025a4 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -2821,20 +2821,39 @@ def _alias_delete(self, args: argparse.Namespace) -> None: @as_subcommand_to('alias', 'list', alias_list_parser, help=alias_delete_help) def _alias_list(self, args: argparse.Namespace) -> None: - """List some or all aliases""" + """List some or all aliases as 'alias create' commands""" create_cmd = "alias create" if args.with_silent: create_cmd += " --silent" + tokens_to_quote = constants.REDIRECTION_TOKENS + tokens_to_quote.extend(self.statement_parser.terminators) + if args.names: - for cur_name in utils.remove_duplicates(args.names): - if cur_name in self.aliases: - self.poutput("{} {} {}".format(create_cmd, cur_name, self.aliases[cur_name])) - else: - self.perror("Alias '{}' not found".format(cur_name)) + to_list = utils.remove_duplicates(args.names) else: - for cur_alias in sorted(self.aliases, key=self.default_sort_key): - self.poutput("{} {} {}".format(create_cmd, cur_alias, self.aliases[cur_alias])) + to_list = sorted(self.aliases, key=self.default_sort_key) + + not_found = [] # type: List[str] + for name in to_list: + if name not in self.aliases: + not_found.append(name) + continue + + # Quote redirection and terminator tokens for the 'alias create' command + tokens = shlex_split(self.aliases[name]) + command = tokens[0] + args = tokens[1:] + utils.quote_specific_tokens(args, tokens_to_quote) + + val = command + if args: + val += ' ' + ' '.join(args) + + self.poutput("{} {} {}".format(create_cmd, name, val)) + + for name in not_found: + self.perror("Alias '{}' not found".format(name)) ############################################################# # Parsers and functions for macro command and subcommands @@ -3029,20 +3048,39 @@ def _macro_delete(self, args: argparse.Namespace) -> None: @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""" + """List some or all macros as 'macro create' commands""" create_cmd = "macro create" if args.with_silent: create_cmd += " --silent" + tokens_to_quote = constants.REDIRECTION_TOKENS + tokens_to_quote.extend(self.statement_parser.terminators) + if args.names: - for cur_name in utils.remove_duplicates(args.names): - if cur_name in self.macros: - self.poutput("{} {} {}".format(create_cmd, cur_name, self.macros[cur_name].value)) - else: - self.perror("Macro '{}' not found".format(cur_name)) + to_list = utils.remove_duplicates(args.names) else: - for cur_macro in sorted(self.macros, key=self.default_sort_key): - self.poutput("{} {} {}".format(create_cmd, cur_macro, self.macros[cur_macro].value)) + to_list = sorted(self.macros, key=self.default_sort_key) + + not_found = [] # type: 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] + args = tokens[1:] + utils.quote_specific_tokens(args, tokens_to_quote) + + val = command + if args: + val += ' ' + ' '.join(args) + + self.poutput("{} {} {}".format(create_cmd, name, val)) + + for name in not_found: + self.perror("Macro '{}' not found".format(name)) def complete_help_command(self, text: str, line: str, begidx: int, endidx: int) -> List[str]: """Completes the command argument of help""" diff --git a/cmd2/utils.py b/cmd2/utils.py index b6dadf1ca..45ca494c8 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -318,17 +318,29 @@ def natural_sort(list_to_sort: Iterable[str]) -> List[str]: return sorted(list_to_sort, key=natural_keys) -def unquote_specific_tokens(args: List[str], tokens_to_unquote: List[str]) -> None: +def quote_specific_tokens(tokens: List[str], tokens_to_quote: List[str]) -> None: """ - Unquote a specific tokens in a list of command-line arguments - This is used when certain tokens have to be passed to another command - :param args: the command line args - :param tokens_to_unquote: the tokens, which if present in args, to unquote + Quote specific tokens in a list + + :param tokens: token list being edited + :param tokens_to_quote: the tokens, which if present in tokens, to quote + """ + for i, token in enumerate(tokens): + if token in tokens_to_quote: + tokens[i] = quote_string(token) + + +def unquote_specific_tokens(tokens: List[str], tokens_to_unquote: List[str]) -> None: + """ + Unquote specific tokens in a list + + :param tokens: token list being edited + :param tokens_to_unquote: the tokens, which if present in tokens, to unquote """ - for i, arg in enumerate(args): - unquoted_arg = strip_quotes(arg) - if unquoted_arg in tokens_to_unquote: - args[i] = unquoted_arg + for i, token in enumerate(tokens): + unquoted_token = strip_quotes(token) + if unquoted_token in tokens_to_unquote: + tokens[i] = unquoted_token def expand_user(token: str) -> str: diff --git a/docs/api/utils.rst b/docs/api/utils.rst index d9166401e..0013bb7a4 100644 --- a/docs/api/utils.rst +++ b/docs/api/utils.rst @@ -22,6 +22,10 @@ Quote Handling .. autofunction:: cmd2.utils.strip_quotes +.. autofunction:: cmd2.utils.quote_specific_tokens + +.. autofunction:: cmd2.utils.unquote_specific_tokens + IO Handling ----------- diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 1e4f48449..034f70961 100755 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1639,16 +1639,17 @@ def test_alias_create(base_app): out, err = run_cmd(base_app, 'alias list --with_silent fake') assert out == normalize('alias create --silent fake set') -def test_alias_create_with_quoted_value(base_app): - """Demonstrate that quotes in alias value will be preserved (except for redirectors and terminators)""" +def test_alias_create_with_quoted_tokens(base_app): + """Demonstrate that quotes in alias value will be preserved""" + create_command = 'alias create fake help ">" "out file.txt" ";"' # Create the alias - out, err = run_cmd(base_app, 'alias create fake help ">" "out file.txt" ";"') + out, err = run_cmd(base_app, create_command) assert out == normalize("Alias 'fake' created") - # Look up the new alias (Only the redirector should be unquoted) + # Look up the new alias and verify all quotes are preserved out, err = run_cmd(base_app, 'alias list fake') - assert out == normalize('alias create fake help > "out file.txt" ;') + assert out == normalize(create_command) @pytest.mark.parametrize('alias_name', invalid_command_name) def test_alias_create_invalid_name(base_app, alias_name, capsys): @@ -1748,15 +1749,17 @@ def test_macro_create(base_app): out, err = run_cmd(base_app, 'macro list --with_silent fake') assert out == normalize('macro create --silent fake set') -def test_macro_create_with_quoted_value(base_app): - """Demonstrate that quotes in macro value will be preserved (except for redirectors and terminators)""" +def test_macro_create_with_quoted_tokens(base_app): + """Demonstrate that quotes in macro value will be preserved""" + create_command = 'macro create fake help ">" "out file.txt" ";"' + # Create the macro - out, err = run_cmd(base_app, 'macro create fake help ">" "out file.txt" ";"') + out, err = run_cmd(base_app, create_command) assert out == normalize("Macro 'fake' created") - # Look up the new macro (Only the redirector should be unquoted) + # Look up the new macro and verify all quotes are preserved out, err = run_cmd(base_app, 'macro list fake') - assert out == normalize('macro create fake help > "out file.txt" ;') + assert out == normalize(create_command) @pytest.mark.parametrize('macro_name', invalid_command_name) def test_macro_create_invalid_name(base_app, macro_name):