From 3928910d6e14699515601ecee23e201d4b7309d1 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 18 Mar 2019 20:45:36 -0400 Subject: Added load -r flag for recording a transcript based on a script file The load command now supports the -r/--record_transcript flag for recording a transcript file based on a script file. --- cmd2/cmd2.py | 38 +++++++++++++++++++++++++++----------- docs/freefeatures.rst | 5 +++++ docs/transcript.rst | 17 ++++++++++++++--- 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 13278b44..893a63dd 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3332,18 +3332,17 @@ 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 + # Disable echo and redirection while we manually redirect stdout to a StringIO buffer + saved_allow_redirection = self.allow_redirection saved_echo = self.echo + saved_stdout = self.stdout + self.allow_redirection = False 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 @@ -3379,10 +3378,10 @@ 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.allow_redirection = saved_allow_redirection self.echo = saved_echo + self.stdout = saved_stdout # finally, we can write the transcript out to the file try: @@ -3438,9 +3437,21 @@ 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('-r', '--record_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',)) @@ -3474,11 +3485,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.record_transcript: + self._generate_transcript(script_commands, args.record_transcript) + return + + self.cmdqueue = script_commands + ['eos'] + self.cmdqueue self._script_dir.append(os.path.dirname(expanded_path)) relative_load_description = load_description diff --git a/docs/freefeatures.rst b/docs/freefeatures.rst index 001a7599..5a08dcd6 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -19,6 +19,11 @@ 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. + .. 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..91679641 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 -r``:: + + (Cmd) load scripts/script.txt -r 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``:: -- cgit v1.2.1 From 96d176cc3d8198913693a42c7dd983cf69a165bd Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 18 Mar 2019 21:25:03 -0400 Subject: Added a unit test for "load -r" --- tests/scripts/help.txt | 1 + tests/test_transcript.py | 31 ++++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 tests/scripts/help.txt 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 @@ +help -v diff --git a/tests/test_transcript.py b/tests/test_transcript.py index f93642b8..df7a7cf9 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 @@ -190,6 +190,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 {} -r {}'.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. -- cgit v1.2.1 From dcbffdb3cf10e6b44b0aac845b372f9766d30dbb Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Mon, 18 Mar 2019 23:45:58 -0400 Subject: Addressed review comments --- cmd2/cmd2.py | 24 +++++++++++++++++------- docs/freefeatures.rst | 6 +++++- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index f46ce496..b66869c1 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3341,6 +3341,9 @@ class Cmd(cmd.Cmd): except Exception as e: self.perror('Saving {!r} - {}'.format(args.output_file, e), traceback_war=False) elif args.transcript: + if self.redirecting: + self.perror("Redirection not supported while using history -t", traceback_war=False) + return self._generate_transcript(history, args.transcript) else: # Display the history items retrieved @@ -3350,12 +3353,9 @@ class Cmd(cmd.Cmd): def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcript_file: str) -> None: """Generate a transcript file from a given history of commands.""" import io - - # Disable echo and redirection while we manually redirect stdout to a StringIO buffer - saved_allow_redirection = self.allow_redirection + # Disable echo while we manually redirect stdout to a StringIO buffer saved_echo = self.echo saved_stdout = self.stdout - self.allow_redirection = False self.echo = False # The problem with supporting regular expressions in transcripts @@ -3394,7 +3394,6 @@ class Cmd(cmd.Cmd): transcript += output.replace('/', r'\/') # Restore altered attributes to their original state - self.allow_redirection = saved_allow_redirection self.echo = saved_echo self.stdout = saved_stdout @@ -3506,7 +3505,18 @@ class Cmd(cmd.Cmd): return if args.record_transcript: - self._generate_transcript(script_commands, args.record_transcript) + if self.redirecting: + self.perror("Redirection not supported while using load -r", traceback_war=False) + return + transcript_path = os.path.abspath(os.path.expanduser(args.record_transcript)) + transcript_dir = os.path.dirname(transcript_path) + if not os.path.isdir(transcript_dir): + self.perror("{!r} is not a directory".format(transcript_dir), traceback_war=False) + return + if not os.access(transcript_dir, os.W_OK): + self.perror("You do not have write access to directory '{!r}".format(transcript_dir), traceback_war=False) + return + self._generate_transcript(script_commands, os.path.expanduser(args.record_transcript)) return self.cmdqueue = script_commands + ['eos'] + self.cmdqueue @@ -3825,7 +3835,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 5a08dcd6..11b5de68 100644 --- a/docs/freefeatures.rst +++ b/docs/freefeatures.rst @@ -22,7 +22,11 @@ Simply include one command per line, typed exactly as you would inside a ``cmd2` 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. +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 -- cgit v1.2.1 From e0a307c03345674bd78ff991b16816f5301d2653 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 19 Mar 2019 00:17:58 -0400 Subject: Updated CHANGELOG Also: - Removed guard clauses which kmvanbrunt promises will be unecessary with his upcoming change - Moved transcript path validation inside _generate_transcript() --- CHANGELOG.md | 1 + cmd2/cmd2.py | 24 ++++++++++-------------- tests/test_transcript.py | 4 ++-- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 694d2786..32cebfc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,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 `-r` 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 b66869c1..46984326 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3341,9 +3341,6 @@ class Cmd(cmd.Cmd): except Exception as e: self.perror('Saving {!r} - {}'.format(args.output_file, e), traceback_war=False) elif args.transcript: - if self.redirecting: - self.perror("Redirection not supported while using history -t", traceback_war=False) - return self._generate_transcript(history, args.transcript) else: # Display the history items retrieved @@ -3353,6 +3350,16 @@ class Cmd(cmd.Cmd): def _generate_transcript(self, history: List[Union[HistoryItem, str]], transcript_file: str) -> None: """Generate a transcript file from a given history of commands.""" 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): + self.perror("Transcript directory {!r} is not a directory".format(transcript_dir), traceback_war=False) + return + if not os.access(transcript_dir, os.W_OK): + self.perror("No write access for transcript directory {!r}".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 @@ -3505,17 +3512,6 @@ class Cmd(cmd.Cmd): return if args.record_transcript: - if self.redirecting: - self.perror("Redirection not supported while using load -r", traceback_war=False) - return - transcript_path = os.path.abspath(os.path.expanduser(args.record_transcript)) - transcript_dir = os.path.dirname(transcript_path) - if not os.path.isdir(transcript_dir): - self.perror("{!r} is not a directory".format(transcript_dir), traceback_war=False) - return - if not os.access(transcript_dir, os.W_OK): - self.perror("You do not have write access to directory '{!r}".format(transcript_dir), traceback_war=False) - return self._generate_transcript(script_commands, os.path.expanduser(args.record_transcript)) return diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 709648fc..6c9b8a20 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -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') -- cgit v1.2.1 From 92ab34edff5cdb4481233cbd49c80b91194570ce Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 19 Mar 2019 00:28:17 -0400 Subject: Combined two conditionals --- cmd2/cmd2.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 46984326..b24d1a48 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3353,11 +3353,9 @@ class Cmd(cmd.Cmd): # 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): - self.perror("Transcript directory {!r} is not a directory".format(transcript_dir), traceback_war=False) - return - if not os.access(transcript_dir, os.W_OK): - self.perror("No write access for transcript directory {!r}".format(transcript_dir), traceback_war=False) + 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 -- cgit v1.2.1 From 0cdc9119de361e76d665c9cab71085fe40677331 Mon Sep 17 00:00:00 2001 From: Todd Leonhardt Date: Tue, 19 Mar 2019 19:39:03 -0400 Subject: Now consistently use -t flag for transcript generation for both history and load commands --- CHANGELOG.md | 2 +- cmd2/cmd2.py | 7 +++---- docs/transcript.rst | 4 ++-- tests/test_transcript.py | 2 +- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32cebfc5..44cc8513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,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 `-r` option to the `load` command for automatically generating a transcript based on a script file + * 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 b24d1a48..71be85f0 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3468,8 +3468,7 @@ class Cmd(cmd.Cmd): ) load_parser = ACArgumentParser(description=load_description) - setattr(load_parser.add_argument('-r', '--record_transcript', - help='record the output of the script as a transcript file'), + 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',)) @@ -3509,8 +3508,8 @@ class Cmd(cmd.Cmd): self.perror("Problem accessing script from '{}': {}".format(expanded_path, ex)) return - if args.record_transcript: - self._generate_transcript(script_commands, os.path.expanduser(args.record_transcript)) + if args.transcript: + self._generate_transcript(script_commands, os.path.expanduser(args.transcript)) return self.cmdqueue = script_commands + ['eos'] + self.cmdqueue diff --git a/docs/transcript.rst b/docs/transcript.rst index 91679641..c7c31fd6 100644 --- a/docs/transcript.rst +++ b/docs/transcript.rst @@ -34,9 +34,9 @@ This is by far the easiest way to generate a transcript. Automatically from a script file -------------------------------- -A transcript can also be automatically generated from a script file using ``load -r``:: +A transcript can also be automatically generated from a script file using ``load -t``:: - (Cmd) load scripts/script.txt -r transcript.txt + (Cmd) load scripts/script.txt -t transcript.txt 2 commands and their outputs saved to transcript file 'transcript.txt' (Cmd) diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 6c9b8a20..acdbe703 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -203,7 +203,7 @@ def test_load_record_transcript(base_app, request): os.close(fd) # Run the load command with the -r option to generate a transcript - run_cmd(base_app, 'load {} -r {}'.format(filename, transcript_fname)) + run_cmd(base_app, 'load {} -t {}'.format(filename, transcript_fname)) assert base_app.cmdqueue == [] assert base_app._script_dir == [] -- cgit v1.2.1