Repository: python-cmd2/cmd2
Pull request #1004 (2020-09-30 18:22:44)

Fixed alias/macro list commands to include quoted redirectors and terminators
Since alias/macro list didn't include the quoted redirectors and terminators, startup scripts did not correctly recreate all aliases and macros.

codecov[bot]: # [Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/1004?src=pr&el=h1) Report
> Merging [#1004](https://codecov.io/gh/python-cmd2/cmd2/pull/1004?src=pr&el=desc) into [master](https://codecov.io/gh/python-cmd2/cmd2/commit/ed7b9e5185fd14808aa26e61a26968b9753beee0?el=desc) will **increase** coverage by `0.01%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/python-cmd2/cmd2/pull/1004/graphs/tree.svg?width=650&height=150&src=pr&token=eks2PUwa6A)](https://codecov.io/gh/python-cmd2/cmd2/pull/1004?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #1004      +/-   ##
==========================================
+ Coverage   97.86%   97.87%   +0.01%     
==========================================
  Files          22       22              
  Lines        4583     4613      +30     
==========================================
+ Hits         4485     4515      +30     
  Misses         98       98              
```


| [Impacted Files](https://codecov.io/gh/python-cmd2/cmd2/pull/1004?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [cmd2/cmd2.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1004/diff?src=pr&el=tree#diff-Y21kMi9jbWQyLnB5) | `97.27% <100.00%> (+0.03%)` | :arrow_up: |
| [cmd2/utils.py](https://codecov.io/gh/python-cmd2/cmd2/pull/1004/diff?src=pr&el=tree#diff-Y21kMi91dGlscy5weQ==) | `97.88% <100.00%> (+0.02%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/1004?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/python-cmd2/cmd2/pull/1004?src=pr&el=footer). Last update [ed7b9e5...7f69609](https://codecov.io/gh/python-cmd2/cmd2/pull/1004?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

tleonhardt: Something seems messed up with TravisCI reporting results to GitHub for us.  But the tests are passing: https://travis-ci.org/github/python-cmd2/cmd2/builds/731691716

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d1f33931..e2ada12f 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 d1a26a2b..8810025a 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -2821,20 +2821,39 @@ class Cmd(cmd.Cmd):
 
     @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 @@ class Cmd(cmd.Cmd):
 
     @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 b6dadf1c..45ca494c 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 d9166401..0013bb7a 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
 -----------


Test patch:
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index 1e4f4844..034f7096 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):