1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
|
#
# -*- coding: utf-8 -*-
"""Machinery for running and validating transcripts.
If the user wants to run a transcript (see docs/transcript.rst),
we need a mechanism to run each command in the transcript as
a unit test, comparing the expected output to the actual output.
This file contains the class necessary to make that work. This
class is used in cmd2.py::run_transcript_tests()
"""
import re
import unittest
from typing import (
TYPE_CHECKING,
Iterator,
List,
Optional,
TextIO,
Tuple,
cast,
)
from . import (
ansi,
utils,
)
if TYPE_CHECKING: # pragma: no cover
from cmd2 import (
Cmd,
)
class Cmd2TestCase(unittest.TestCase):
"""A unittest class used for transcript testing.
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: Optional['Cmd'] = None
def setUp(self) -> None:
if self.cmdapp:
self._fetchTranscripts()
# Trap stdout
self._orig_stdout = self.cmdapp.stdout
self.cmdapp.stdout = cast(TextIO, utils.StdSim(cast(TextIO, self.cmdapp.stdout)))
def tearDown(self) -> None:
if self.cmdapp:
# Restore stdout
self.cmdapp.stdout = self._orig_stdout
def runTest(self) -> None: # was testall
if self.cmdapp:
its = sorted(self.transcripts.items())
for fname, transcript in its:
self._test_transcript(fname, transcript)
def _fetchTranscripts(self) -> None:
self.transcripts = {}
testfiles = cast(List[str], getattr(self.cmdapp, 'testfiles', []))
for fname in testfiles:
tfile = open(fname)
self.transcripts[fname] = iter(tfile.readlines())
tfile.close()
def _test_transcript(self, fname: str, transcript: Iterator[str]) -> None:
if self.cmdapp is None:
return
line_num = 0
finished = False
line = ansi.strip_style(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 = ansi.strip_style(next(transcript))
except StopIteration:
finished = True
break
line_num += 1
command_parts = [line[len(self.cmdapp.visible_prompt) :]]
try:
line = next(transcript)
except StopIteration:
line = ''
line_num += 1
# Read the entirety of a multi-line command
while line.startswith(self.cmdapp.continuation_prompt):
command_parts.append(line[len(self.cmdapp.continuation_prompt) :])
try:
line = next(transcript)
except StopIteration as exc:
msg = f'Transcript broke off while reading command beginning at line {line_num} with\n{command_parts[0]}'
raise StopIteration(msg) from exc
line_num += 1
command = ''.join(command_parts)
# Send the command into the application and capture the resulting output
stop = self.cmdapp.onecmd_plus_hooks(command)
result = self.cmdapp.stdout.read()
stop_msg = 'Command indicated application should quit, but more commands in transcript'
# Read the expected result from transcript
if ansi.strip_style(line).startswith(self.cmdapp.visible_prompt):
message = f'\nFile {fname}, line {line_num}\nCommand was:\n{command}\nExpected: (nothing)\nGot:\n{result}\n'
self.assertTrue(not (result.strip()), message)
# If the command signaled the application to quit there should be no more commands
self.assertFalse(stop, stop_msg)
continue
expected_parts = []
while not ansi.strip_style(line).startswith(self.cmdapp.visible_prompt):
expected_parts.append(line)
try:
line = next(transcript)
except StopIteration:
finished = True
break
line_num += 1
if stop:
# This should only be hit if the command that set stop to True had output text
self.assertTrue(finished, stop_msg)
# transform the expected text into a valid regular expression
expected = ''.join(expected_parts)
expected = self._transform_transcript_expected(expected)
message = f'\nFile {fname}, line {line_num}\nCommand was:\n{command}\nExpected:\n{expected}\nGot:\n{result}\n'
self.assertTrue(re.match(expected, result, re.MULTILINE | re.DOTALL), message)
def _transform_transcript_expected(self, s: str) -> str:
r"""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: str, s: str, start: int, in_regex: bool) -> Tuple[str, int, int]:
"""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
|