Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
43 changes: 31 additions & 12 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -3350,18 +3350,22 @@ def do_history(self, args: argparse.Namespace) -> None:
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
Expand Down Expand Up @@ -3397,10 +3401,9 @@ def _generate_transcript(self, history: List[HistoryItem], transcript_file: str)
# 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:
Expand Down Expand Up @@ -3456,9 +3459,20 @@ def do_eos(self, _: argparse.Namespace) -> None:
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',))

Expand Down Expand Up @@ -3492,11 +3506,16 @@ def do_load(self, args: argparse.Namespace) -> None:
# 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
Expand Down Expand Up @@ -3812,7 +3831,7 @@ def cmdloop(self, intro: Optional[str] = None) -> None:

# 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:
Expand Down
9 changes: 9 additions & 0 deletions docs/freefeatures.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions docs/transcript.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
...
Expand All @@ -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``::
Expand Down
1 change: 1 addition & 0 deletions tests/scripts/help.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
help -v
35 changes: 32 additions & 3 deletions tests/test_transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
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

Expand Down Expand Up @@ -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')
Expand All @@ -163,7 +163,7 @@ def test_history_transcript(request, capsys):

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')
Expand All @@ -189,6 +189,35 @@ def test_history_transcript_bad_filename(request, capsys):
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.
Expand Down