Repository: python-cmd2/cmd2

Issue:
transcript generation/update
Lately I've been working a lot on my program, tinkering with the output and all.
I miss having more tests to check for regression.
I had started creating some transcripts but they got outdated and I am not sure what's the best way to update them (from scratch ?).
Right now I believe the main approach is using `history` and because my program accepts commands from the command line, I can run
`mptcpanalyzer -d DEBUG -l ~/pcaps/tmpatm6409s/client_2_.pcapng "summary_extended ~/pcaps/tmpatm6409s/client_2_.pcapng 1 ~/pcaps/tmpatm6409s/server_2_.pcapng 1" "history 1:1 -t transcript.txt"  "quit"`
but I would love to be able to update transcripts in place. Like for instance I have the following transcript.txt
```
server_2_filtered.pcapng> load_pcap examples/server_2_filtered.pcapng
Loading examples\/server_2_filtered.pcapng
server_2_filtered.pcapng> summary 0 Client
mptcpstream 0 transferred 308 bytes.
tcpstream 0 transferred 308 bytes out of 308, accounting for 100.00%
tcpstream 2 transferred 0 bytes out of 308, accounting for 0.00%
tcpstream 6 transferred 0 bytes out of 308, accounting for 0.00%
server_2_filtered.pcapng> summary 0 Server
mptcpstream 0 transferred 461 bytes.
tcpstream 0 transferred 456 bytes out of 461, accounting for 98.92%
tcpstream 2 transferred 5 bytes out of 461, accounting for 1.08%
tcpstream 6 transferred 0 bytes out of 461, accounting for 0.00%
```
And I could run a command for instance `transcript update transcript.txt` that reruns the commands and updates the outputs accordingly.

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c35a182b..da3d0751 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@
     * The `with_argparser` decorators now add the Statement object created when parsing the command line to the
     `argparse.Namespace` object they pass to the `do_*` methods. It is stored in an attribute called `__statement__`.
     This can be useful if a command function needs to know the command line for things like logging.
+    * Added a `-t` option to the `load` command for automatically generating a transcript based on a script file
 * Potentially breaking changes
     * The following commands now write to stderr instead of stdout when printing an error. This will make catching
     errors easier in pyscript.
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 33cfe3fc..0aaeb4c3 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -3350,18 +3350,22 @@ class Cmd(cmd.Cmd):
             for hi in history:
                 self.poutput(hi.pr(script=args.script, expanded=args.expanded, verbose=args.verbose))
 
-    def _generate_transcript(self, history: List[HistoryItem], transcript_file: str) -> None:
+    def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcript_file: str) -> None:
         """Generate a transcript file from a given history of commands."""
-        # Save the current echo state, and turn it off. We inject commands into the
-        # output using a different mechanism
         import io
+        # Validate the transcript file path to make sure directory exists and write access is available
+        transcript_path = os.path.abspath(os.path.expanduser(transcript_file))
+        transcript_dir = os.path.dirname(transcript_path)
+        if not os.path.isdir(transcript_dir) or not os.access(transcript_dir, os.W_OK):
+            self.perror("{!r} is not a directory or you don't have write access".format(transcript_dir),
+                        traceback_war=False)
+            return
 
+        # Disable echo while we manually redirect stdout to a StringIO buffer
         saved_echo = self.echo
+        saved_stdout = self.stdout
         self.echo = False
 
-        # Redirect stdout to the transcript file
-        saved_self_stdout = self.stdout
-
         # The problem with supporting regular expressions in transcripts
         # is that they shouldn't be processed in the command, just the output.
         # In addition, when we generate a transcript, any slashes in the output
@@ -3397,10 +3401,9 @@ class Cmd(cmd.Cmd):
             # and add the regex-escaped output to the transcript
             transcript += output.replace('/', r'\/')
 
-        # Restore stdout to its original state
-        self.stdout = saved_self_stdout
-        # Set echo back to its original state
+        # Restore altered attributes to their original state
         self.echo = saved_echo
+        self.stdout = saved_stdout
 
         # finally, we can write the transcript out to the file
         try:
@@ -3456,9 +3459,20 @@ class Cmd(cmd.Cmd):
     load_description = ("Run commands in script file that is encoded as either ASCII or UTF-8 text\n"
                         "\n"
                         "Script should contain one command per line, just like the command would be\n"
-                        "typed in the console.")
+                        "typed in the console.\n"
+                        "\n"
+                        "It loads commands from a script file into a queue and then the normal cmd2\n"
+                        "REPL resumes control and executes the commands in the queue in FIFO order.\n"
+                        "If you attempt to redirect/pipe a load command, it will capture the output\n"
+                        "of the load command itself, not what it adds to the queue.\n"
+                        "\n"
+                        "If the -r/--record_transcript flag is used, this command instead records\n"
+                        "the output of the script commands to a transcript for testing purposes.\n"
+                        )
 
     load_parser = ACArgumentParser(description=load_description)
+    setattr(load_parser.add_argument('-t', '--transcript', help='record the output of the script as a transcript file'),
+            ACTION_ARG_CHOICES, ('path_complete',))
     setattr(load_parser.add_argument('script_path', help="path to the script file"),
             ACTION_ARG_CHOICES, ('path_complete',))
 
@@ -3492,11 +3506,16 @@ class Cmd(cmd.Cmd):
             # command queue. Add an "end of script (eos)" command to cleanup the
             # self._script_dir list when done.
             with open(expanded_path, encoding='utf-8') as target:
-                self.cmdqueue = target.read().splitlines() + ['eos'] + self.cmdqueue
+                script_commands = target.read().splitlines()
         except OSError as ex:  # pragma: no cover
             self.perror("Problem accessing script from '{}': {}".format(expanded_path, ex))
             return
 
+        if args.transcript:
+            self._generate_transcript(script_commands, os.path.expanduser(args.transcript))
+            return
+
+        self.cmdqueue = script_commands + ['eos'] + self.cmdqueue
         self._script_dir.append(os.path.dirname(expanded_path))
 
     relative_load_description = load_description
@@ -3812,7 +3831,7 @@ class Cmd(cmd.Cmd):
 
         # If transcript-based regression testing was requested, then do that instead of the main loop
         if self._transcript_files is not None:
-            self.run_transcript_tests(self._transcript_files)
+            self.run_transcript_tests([os.path.expanduser(tf) for tf in self._transcript_files])
         else:
             # If an intro was supplied in the method call, allow it to override the default
             if intro is not None:
diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst
index 001a7599..11b5de68 100644
--- a/docs/freefeatures.rst
+++ b/docs/freefeatures.rst
@@ -19,6 +19,15 @@ Both ASCII and UTF-8 encoded unicode text files are supported.
 
 Simply include one command per line, typed exactly as you would inside a ``cmd2`` application.
 
+The ``load`` command loads commands from a script file into a queue and then the normal cmd2 REPL
+resumes control and executes the commands in the queue in FIFO order.  A side effect of this
+is that if you redirect/pipe the output of a load command, it will redirect the output of the ``load``
+command itself, but will NOT redirect the output of the command loaded from the script file.  Of course,
+you can add redirection to the commands being run in the script file, e.g.::
+
+    # This is your script file
+    command arg1 arg2 > file.txt
+
 .. automethod:: cmd2.cmd2.Cmd.do_load
 
 .. automethod:: cmd2.cmd2.Cmd.do__relative_load
diff --git a/docs/transcript.rst b/docs/transcript.rst
index 36b35fcf..c7c31fd6 100644
--- a/docs/transcript.rst
+++ b/docs/transcript.rst
@@ -13,9 +13,9 @@ from commands that produce dynamic or variable output.
 Creating a transcript
 =====================
 
-Automatically
--------------
-A transcript can automatically generated based upon commands previously executed in the *history*::
+Automatically from history
+--------------------------
+A transcript can automatically generated based upon commands previously executed in the *history* using ``history -t``::
 
     (Cmd) help
     ...
@@ -32,6 +32,17 @@ This is by far the easiest way to generate a transcript.
    of the ``cmd2.Cmd`` class ensure that output is properly redirected when redirecting to a file, piping to a shell
    command, and when generating a transcript.
 
+Automatically from a script file
+--------------------------------
+A transcript can also be automatically generated from a script file using ``load -t``::
+
+    (Cmd) load scripts/script.txt -t transcript.txt
+    2 commands and their outputs saved to transcript file 'transcript.txt'
+    (Cmd)
+
+This is a particularly attractive option for automatically regenerating transcripts for regression testing as your ``cmd2``
+application changes.
+
 Manually
 --------
 Here's a transcript created from ``python examples/example.py``::


Test patch:
diff --git a/tests/scripts/help.txt b/tests/scripts/help.txt
new file mode 100644
index 00000000..71d23d97
--- /dev/null
+++ b/tests/scripts/help.txt
@@ -0,0 +1,1 @@
+help -v
diff --git a/tests/test_transcript.py b/tests/test_transcript.py
index 4a03ebe0..acdbe703 100644
--- a/tests/test_transcript.py
+++ b/tests/test_transcript.py
@@ -17,7 +17,7 @@ from unittest import mock
 import pytest
 
 import cmd2
-from .conftest import run_cmd
+from .conftest import run_cmd, BASE_HELP_VERBOSE
 from cmd2 import transcript
 from cmd2.utils import StdSim
 
@@ -136,7 +136,7 @@ def test_transcript(request, capsys, filename, feedback_to_output):
     assert err.startswith(expected_start)
     assert err.endswith(expected_end)
 
-def test_history_transcript(request, capsys):
+def test_history_transcript():
     app = CmdLineApp()
     app.stdout = StdSim(app.stdout)
     run_cmd(app, 'orate this is\na /multiline/\ncommand;\n')
@@ -163,7 +163,7 @@ this is a \/multiline\/ command
 
     assert xscript == expected
 
-def test_history_transcript_bad_filename(request, capsys):
+def test_history_transcript_bad_filename():
     app = CmdLineApp()
     app.stdout = StdSim(app.stdout)
     run_cmd(app, 'orate this is\na /multiline/\ncommand;\n')
@@ -189,6 +189,35 @@ this is a \/multiline\/ command
             transcript = f.read()
         assert transcript == expected
 
+
+def test_load_record_transcript(base_app, request):
+    test_dir = os.path.dirname(request.module.__file__)
+    filename = os.path.join(test_dir, 'scripts', 'help.txt')
+
+    assert base_app.cmdqueue == []
+    assert base_app._script_dir == []
+    assert base_app._current_script_dir is None
+
+    # make a tmp file to use as a transcript
+    fd, transcript_fname = tempfile.mkstemp(prefix='', suffix='.trn')
+    os.close(fd)
+
+    # Run the load command with the -r option to generate a transcript
+    run_cmd(base_app, 'load {} -t {}'.format(filename, transcript_fname))
+
+    assert base_app.cmdqueue == []
+    assert base_app._script_dir == []
+    assert base_app._current_script_dir is None
+
+    # read in the transcript created by the history command
+    with open(transcript_fname) as f:
+        xscript = f.read()
+
+    expected = '(Cmd) help -v\n' + BASE_HELP_VERBOSE + '\n'
+
+    assert xscript == expected
+
+
 @pytest.mark.parametrize('expected, transformed', [
     # strings with zero or one slash or with escaped slashes means no regular
     # expression present, so the result should just be what re.escape returns.