diff options
-rwxr-xr-x | cmd2/cmd2.py | 214 | ||||
-rw-r--r-- | cmd2/transcript.py | 213 | ||||
-rw-r--r-- | tests/test_transcript.py | 3 |
3 files changed, 218 insertions, 212 deletions
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 1378f052..9428afb3 100755 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -22,6 +22,7 @@ is used in place of `print`. Git repository on GitHub at https://github.com/python-cmd2/cmd2 """ +# many imports are lazy-loaded when they are needed import argparse import atexit import cmd @@ -43,7 +44,6 @@ import sys import tempfile import traceback from typing import Callable, List, Optional, Union, Tuple -import unittest from code import InteractiveConsole import pyperclip @@ -3174,6 +3174,8 @@ Script should contain one command per line, just like command would be typed in :param callargs: List[str] - list of transcript test file names """ + import unittest + from .transcript import Cmd2TestCase class TestMyAppCase(Cmd2TestCase): cmdapp = self @@ -3416,216 +3418,6 @@ class Statekeeper(object): setattr(self.obj, attrib, getattr(self, attrib)) -class OutputTrap(object): - """Instantiate an OutputTrap to divert/capture ALL stdout output. For use in transcript testing.""" - - def __init__(self): - self.contents = '' - - def write(self, txt): - """Add text to the internal contents. - - :param txt: str - """ - self.contents += txt - - def read(self): - """Read from the internal contents and then clear them out. - - :return: str - text from the internal contents - """ - result = self.contents - self.contents = '' - return result - - -class Cmd2TestCase(unittest.TestCase): - """Subclass this, setting CmdApp, to make a unittest.TestCase class - that will execute the commands in a transcript file and expect the results shown. - See example.py""" - cmdapp = None - - def fetchTranscripts(self): - self.transcripts = {} - for fileset in self.cmdapp.testfiles: - for fname in glob.glob(fileset): - tfile = open(fname) - self.transcripts[fname] = iter(tfile.readlines()) - tfile.close() - if not len(self.transcripts): - raise Exception("No test files found - nothing to test.") - - def setUp(self): - if self.cmdapp: - self.fetchTranscripts() - - # Trap stdout - self._orig_stdout = self.cmdapp.stdout - self.cmdapp.stdout = OutputTrap() - - def runTest(self): # was testall - if self.cmdapp: - its = sorted(self.transcripts.items()) - for (fname, transcript) in its: - self._test_transcript(fname, transcript) - - def _test_transcript(self, fname, transcript): - line_num = 0 - finished = False - line = utils.strip_ansi(next(transcript)) - line_num += 1 - while not finished: - # Scroll forward to where actual commands begin - while not line.startswith(self.cmdapp.visible_prompt): - try: - line = utils.strip_ansi(next(transcript)) - except StopIteration: - finished = True - break - line_num += 1 - command = [line[len(self.cmdapp.visible_prompt):]] - line = next(transcript) - # Read the entirety of a multi-line command - while line.startswith(self.cmdapp.continuation_prompt): - command.append(line[len(self.cmdapp.continuation_prompt):]) - try: - line = next(transcript) - except StopIteration: - raise (StopIteration, - 'Transcript broke off while reading command beginning at line {} with\n{}'.format(line_num, - command[0]) - ) - line_num += 1 - command = ''.join(command) - # Send the command into the application and capture the resulting output - # TODO: Should we get the return value and act if stop == True? - self.cmdapp.onecmd_plus_hooks(command) - result = self.cmdapp.stdout.read() - # Read the expected result from transcript - if utils.strip_ansi(line).startswith(self.cmdapp.visible_prompt): - message = '\nFile {}, line {}\nCommand was:\n{}\nExpected: (nothing)\nGot:\n{}\n'.format( - fname, line_num, command, result) - self.assert_(not (result.strip()), message) - continue - expected = [] - while not utils.strip_ansi(line).startswith(self.cmdapp.visible_prompt): - expected.append(line) - try: - line = next(transcript) - except StopIteration: - finished = True - break - line_num += 1 - expected = ''.join(expected) - - # transform the expected text into a valid regular expression - expected = self._transform_transcript_expected(expected) - message = '\nFile {}, line {}\nCommand was:\n{}\nExpected:\n{}\nGot:\n{}\n'.format( - fname, line_num, command, expected, result) - self.assertTrue(re.match(expected, result, re.MULTILINE | re.DOTALL), message) - - def _transform_transcript_expected(self, s): - """parse the string with slashed regexes into a valid regex - - Given a string like: - - Match a 10 digit phone number: /\d{3}-\d{3}-\d{4}/ - - Turn it into a valid regular expression which matches the literal text - of the string and the regular expression. We have to remove the slashes - because they differentiate between plain text and a regular expression. - Unless the slashes are escaped, in which case they are interpreted as - plain text, or there is only one slash, which is treated as plain text - also. - - Check the tests in tests/test_transcript.py to see all the edge - cases. - """ - regex = '' - start = 0 - - while True: - (regex, first_slash_pos, start) = self._escaped_find(regex, s, start, False) - if first_slash_pos == -1: - # no more slashes, add the rest of the string and bail - regex += re.escape(s[start:]) - break - else: - # there is a slash, add everything we have found so far - # add stuff before the first slash as plain text - regex += re.escape(s[start:first_slash_pos]) - start = first_slash_pos+1 - # and go find the next one - (regex, second_slash_pos, start) = self._escaped_find(regex, s, start, True) - if second_slash_pos > 0: - # add everything between the slashes (but not the slashes) - # as a regular expression - regex += s[start:second_slash_pos] - # and change where we start looking for slashed on the - # turn through the loop - start = second_slash_pos + 1 - else: - # No closing slash, we have to add the first slash, - # and the rest of the text - regex += re.escape(s[start-1:]) - break - return regex - - @staticmethod - def _escaped_find(regex, s, start, in_regex): - """ - Find the next slash in {s} after {start} that is not preceded by a backslash. - - If we find an escaped slash, add everything up to and including it to regex, - updating {start}. {start} therefore serves two purposes, tells us where to start - looking for the next thing, and also tells us where in {s} we have already - added things to {regex} - - {in_regex} specifies whether we are currently searching in a regex, we behave - differently if we are or if we aren't. - """ - - while True: - pos = s.find('/', start) - if pos == -1: - # no match, return to caller - break - elif pos == 0: - # slash at the beginning of the string, so it can't be - # escaped. We found it. - break - else: - # check if the slash is preceeded by a backslash - if s[pos-1:pos] == '\\': - # it is. - if in_regex: - # add everything up to the backslash as a - # regular expression - regex += s[start:pos-1] - # skip the backslash, and add the slash - regex += s[pos] - else: - # add everything up to the backslash as escaped - # plain text - regex += re.escape(s[start:pos-1]) - # and then add the slash as escaped - # plain text - regex += re.escape(s[pos]) - # update start to show we have handled everything - # before it - start = pos+1 - # and continue to look - else: - # slash is not escaped, this is what we are looking for - break - return regex, pos, start - - def tearDown(self): - if self.cmdapp: - # Restore stdout - self.cmdapp.stdout = self._orig_stdout - - def namedtuple_with_two_defaults(typename, field_names, default_values=('', '')): """Wrapper around namedtuple which lets you treat the last value as optional. diff --git a/cmd2/transcript.py b/cmd2/transcript.py new file mode 100644 index 00000000..7cf15d7d --- /dev/null +++ b/cmd2/transcript.py @@ -0,0 +1,213 @@ +# +# -*- coding: utf-8 -*- +import re +import glob +import unittest + +from . import utils + +class Cmd2TestCase(unittest.TestCase): + """Subclass this, setting CmdApp, to make a unittest.TestCase class + that will execute the commands in a transcript file and expect the results shown. + See example.py""" + cmdapp = None + + def fetchTranscripts(self): + self.transcripts = {} + for fileset in self.cmdapp.testfiles: + for fname in glob.glob(fileset): + tfile = open(fname) + self.transcripts[fname] = iter(tfile.readlines()) + tfile.close() + if not len(self.transcripts): + raise Exception("No test files found - nothing to test.") + + def setUp(self): + if self.cmdapp: + self.fetchTranscripts() + + # Trap stdout + self._orig_stdout = self.cmdapp.stdout + self.cmdapp.stdout = OutputTrap() + + def runTest(self): # was testall + if self.cmdapp: + its = sorted(self.transcripts.items()) + for (fname, transcript) in its: + self._test_transcript(fname, transcript) + + def _test_transcript(self, fname, transcript): + line_num = 0 + finished = False + line = utils.strip_ansi(next(transcript)) + line_num += 1 + while not finished: + # Scroll forward to where actual commands begin + while not line.startswith(self.cmdapp.visible_prompt): + try: + line = utils.strip_ansi(next(transcript)) + except StopIteration: + finished = True + break + line_num += 1 + command = [line[len(self.cmdapp.visible_prompt):]] + line = next(transcript) + # Read the entirety of a multi-line command + while line.startswith(self.cmdapp.continuation_prompt): + command.append(line[len(self.cmdapp.continuation_prompt):]) + try: + line = next(transcript) + except StopIteration as exc: + msg = 'Transcript broke off while reading command beginning at line {} with\n{}'.format(line_num, command[0]) + raise StopIteration(msg) from exc + line_num += 1 + command = ''.join(command) + # Send the command into the application and capture the resulting output + # TODO: Should we get the return value and act if stop == True? + self.cmdapp.onecmd_plus_hooks(command) + result = self.cmdapp.stdout.read() + # Read the expected result from transcript + if utils.strip_ansi(line).startswith(self.cmdapp.visible_prompt): + message = '\nFile {}, line {}\nCommand was:\n{}\nExpected: (nothing)\nGot:\n{}\n'.format( + fname, line_num, command, result) + self.assert_(not (result.strip()), message) + continue + expected = [] + while not utils.strip_ansi(line).startswith(self.cmdapp.visible_prompt): + expected.append(line) + try: + line = next(transcript) + except StopIteration: + finished = True + break + line_num += 1 + expected = ''.join(expected) + + # transform the expected text into a valid regular expression + expected = self._transform_transcript_expected(expected) + message = '\nFile {}, line {}\nCommand was:\n{}\nExpected:\n{}\nGot:\n{}\n'.format( + fname, line_num, command, expected, result) + self.assertTrue(re.match(expected, result, re.MULTILINE | re.DOTALL), message) + + def _transform_transcript_expected(self, s): + """parse the string with slashed regexes into a valid regex + + Given a string like: + + Match a 10 digit phone number: /\d{3}-\d{3}-\d{4}/ + + Turn it into a valid regular expression which matches the literal text + of the string and the regular expression. We have to remove the slashes + because they differentiate between plain text and a regular expression. + Unless the slashes are escaped, in which case they are interpreted as + plain text, or there is only one slash, which is treated as plain text + also. + + Check the tests in tests/test_transcript.py to see all the edge + cases. + """ + regex = '' + start = 0 + + while True: + (regex, first_slash_pos, start) = self._escaped_find(regex, s, start, False) + if first_slash_pos == -1: + # no more slashes, add the rest of the string and bail + regex += re.escape(s[start:]) + break + else: + # there is a slash, add everything we have found so far + # add stuff before the first slash as plain text + regex += re.escape(s[start:first_slash_pos]) + start = first_slash_pos+1 + # and go find the next one + (regex, second_slash_pos, start) = self._escaped_find(regex, s, start, True) + if second_slash_pos > 0: + # add everything between the slashes (but not the slashes) + # as a regular expression + regex += s[start:second_slash_pos] + # and change where we start looking for slashed on the + # turn through the loop + start = second_slash_pos + 1 + else: + # No closing slash, we have to add the first slash, + # and the rest of the text + regex += re.escape(s[start-1:]) + break + return regex + + @staticmethod + def _escaped_find(regex, s, start, in_regex): + """ + Find the next slash in {s} after {start} that is not preceded by a backslash. + + If we find an escaped slash, add everything up to and including it to regex, + updating {start}. {start} therefore serves two purposes, tells us where to start + looking for the next thing, and also tells us where in {s} we have already + added things to {regex} + + {in_regex} specifies whether we are currently searching in a regex, we behave + differently if we are or if we aren't. + """ + + while True: + pos = s.find('/', start) + if pos == -1: + # no match, return to caller + break + elif pos == 0: + # slash at the beginning of the string, so it can't be + # escaped. We found it. + break + else: + # check if the slash is preceeded by a backslash + if s[pos-1:pos] == '\\': + # it is. + if in_regex: + # add everything up to the backslash as a + # regular expression + regex += s[start:pos-1] + # skip the backslash, and add the slash + regex += s[pos] + else: + # add everything up to the backslash as escaped + # plain text + regex += re.escape(s[start:pos-1]) + # and then add the slash as escaped + # plain text + regex += re.escape(s[pos]) + # update start to show we have handled everything + # before it + start = pos+1 + # and continue to look + else: + # slash is not escaped, this is what we are looking for + break + return regex, pos, start + + def tearDown(self): + if self.cmdapp: + # Restore stdout + self.cmdapp.stdout = self._orig_stdout + +class OutputTrap(object): + """Instantiate an OutputTrap to divert/capture ALL stdout output. For use in transcript testing.""" + + def __init__(self): + self.contents = '' + + def write(self, txt): + """Add text to the internal contents. + + :param txt: str + """ + self.contents += txt + + def read(self): + """Read from the internal contents and then clear them out. + + :return: str - text from the internal contents + """ + result = self.contents + self.contents = '' + return result diff --git a/tests/test_transcript.py b/tests/test_transcript.py index 70658161..99d4735e 100644 --- a/tests/test_transcript.py +++ b/tests/test_transcript.py @@ -17,6 +17,7 @@ import pytest from cmd2 import cmd2 from .conftest import run_cmd, StdOut +from cmd2 import transcript class CmdLineApp(cmd2.Cmd): @@ -177,7 +178,7 @@ this is a \/multiline\/ command def test_parse_transcript_expected(expected, transformed): app = CmdLineApp() - class TestMyAppCase(cmd2.Cmd2TestCase): + class TestMyAppCase(transcript.Cmd2TestCase): cmdapp = app testcase = TestMyAppCase() |