1
# Copyright (C) 2009, 2010 Canonical Ltd
3
# This program is free software; you can redistribute it and/or modify
4
# it under the terms of the GNU General Public License as published by
5
# the Free Software Foundation; either version 2 of the License, or
6
# (at your option) any later version.
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License
14
# along with this program; if not, write to the Free Software
15
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
17
"""Shell-like test scripts.
19
See developers/testing.html for more explanations.
28
from cStringIO import StringIO
39
"""Split a command line respecting quotes."""
40
scanner = shlex.shlex(s)
41
scanner.quotes = '\'"`'
42
scanner.whitespace_split = True
43
for t in list(scanner):
47
def _script_to_commands(text, file_name=None):
48
"""Turn a script into a list of commands with their associated IOs.
50
Each command appears on a line by itself starting with '$ '. It can be
51
associated with an input that will feed it and an expected output.
53
Comments starts with '#' until the end of line.
54
Empty lines are ignored.
56
Input and output are full lines terminated by a '\n'.
58
Input lines start with '<'.
59
Output lines start with nothing.
60
Error lines start with '2>'.
62
:return: A sequence of ([args], input, output, errors), where the args are
63
split in to words, and the input, output, and errors are just strings,
64
typically containing newlines.
69
def add_command(cmd, input, output, error):
72
input = ''.join(input)
73
if output is not None:
74
output = ''.join(output)
76
error = ''.join(error)
77
commands.append((cmd, input, output, error))
82
input, output, error = None, None, None
83
text = textwrap.dedent(text)
84
lines = text.split('\n')
85
# to make use of triple-quoted strings easier, we ignore a blank line
86
# right at the start and right at the end; the rest are meaningful
87
if lines and lines[0] == '':
89
if lines and lines[-1] == '':
93
# Keep a copy for error reporting
95
comment = line.find('#')
98
# NB: this syntax means comments are allowed inside output, which
100
line = line[0:comment]
104
if line.startswith('$'):
105
# Time to output the current command
106
add_command(cmd_cur, input, output, error)
107
# And start a new one
108
cmd_cur = list(split(line[1:]))
110
input, output, error = None, None, None
111
elif line.startswith('<'):
114
raise SyntaxError('No command for that input',
115
(file_name, lineno, 1, orig))
117
input.append(line[1:] + '\n')
118
elif line.startswith('2>'):
121
raise SyntaxError('No command for that error',
122
(file_name, lineno, 1, orig))
124
error.append(line[2:] + '\n')
126
# can happen if the first line is not recognized as a command, eg
127
# if the prompt has leading whitespace
130
raise SyntaxError('No command for line %r' % (line,),
131
(file_name, lineno, 1, orig))
133
output.append(line + '\n')
134
# Add the last seen command
135
add_command(cmd_cur, input, output, error)
139
def _scan_redirection_options(args):
140
"""Recognize and process input and output redirections.
142
:param args: The command line arguments
144
:return: A tuple containing:
145
- The file name redirected from or None
146
- The file name redirected to or None
147
- The mode to open the output file or None
148
- The reamining arguments
150
def redirected_file_name(direction, name, args):
155
# We leave the error handling to higher levels, an empty name
162
out_name, out_mode = None, None
165
if arg.startswith('<'):
166
in_name = redirected_file_name('<', arg[1:], args)
167
elif arg.startswith('>>'):
168
out_name = redirected_file_name('>>', arg[2:], args)
170
elif arg.startswith('>',):
171
out_name = redirected_file_name('>', arg[1:], args)
174
remaining.append(arg)
175
return in_name, out_name, out_mode, remaining
178
class ScriptRunner(object):
179
"""Run a shell-like script from a test.
183
from bzrlib.tests import script
187
def test_bug_nnnnn(self):
188
sr = script.ScriptRunner()
189
sr.run_script(self, '''
197
self.output_checker = doctest.OutputChecker()
198
self.check_options = doctest.ELLIPSIS
200
def run_script(self, test_case, text):
201
"""Run a shell-like script as a test.
203
:param test_case: A TestCase instance that should provide the fail(),
204
assertEqualDiff and _run_bzr_core() methods as well as a 'test_dir'
205
attribute used as a jail root.
207
:param text: A shell-like script (see _script_to_commands for syntax).
209
for cmd, input, output, error in _script_to_commands(text):
210
self.run_command(test_case, cmd, input, output, error)
212
def run_command(self, test_case, cmd, input, output, error):
213
mname = 'do_' + cmd[0]
214
method = getattr(self, mname, None)
216
raise SyntaxError('Command not found "%s"' % (cmd[0],),
217
None, 1, ' '.join(cmd))
221
str_input = ''.join(input)
222
args = list(self._pre_process_args(cmd[1:]))
223
retcode, actual_output, actual_error = method(test_case,
227
self._check_output(output, actual_output, test_case)
228
except AssertionError, e:
229
raise AssertionError(str(e) + " in stdout of command %s" % cmd)
231
self._check_output(error, actual_error, test_case)
232
except AssertionError, e:
233
raise AssertionError(str(e) +
234
" in stderr of running command %s" % cmd)
235
if retcode and not error and actual_error:
236
test_case.fail('In \n\t%s\nUnexpected error: %s'
237
% (' '.join(cmd), actual_error))
238
return retcode, actual_output, actual_error
240
def _check_output(self, expected, actual, test_case):
244
elif expected == '...\n':
247
test_case.fail('expected output: %r, but found nothing'
249
expected = expected or ''
250
matching = self.output_checker.check_output(
251
expected, actual, self.check_options)
253
# Note that we can't use output_checker.output_difference() here
254
# because... the API is broken ('expected' must be a doctest
255
# specific object of which a 'want' attribute will be our
256
# 'expected' parameter. So we just fallback to our good old
257
# assertEqualDiff since we know there *are* differences and the
258
# output should be decently readable.
260
# As a special case, we allow output that's missing a final
261
# newline to match an expected string that does have one, so that
262
# we can match a prompt printed on one line, then input given on
264
if expected == actual + '\n':
267
test_case.assertEqualDiff(expected, actual)
269
def _pre_process_args(self, args):
272
# Strip the simple and double quotes since we don't care about
273
# them. We leave the backquotes in place though since they have a
274
# different semantic.
275
if arg[0] in ('"', "'") and arg[0] == arg[-1]:
278
if glob.has_magic(arg):
279
matches = glob.glob(arg)
281
# We care more about order stability than performance
289
def _read_input(self, input, in_name):
290
if in_name is not None:
291
infile = open(in_name, 'rb')
293
# Command redirection takes precedence over provided input
294
input = infile.read()
299
def _write_output(self, output, out_name, out_mode):
300
if out_name is not None:
301
outfile = open(out_name, out_mode)
303
outfile.write(output)
309
def do_bzr(self, test_case, input, args):
310
retcode, out, err = test_case._run_bzr_core(
311
args, retcode=None, encoding=None, stdin=input, working_dir=None)
312
return retcode, out, err
314
def do_cat(self, test_case, input, args):
315
(in_name, out_name, out_mode, args) = _scan_redirection_options(args)
316
if args and in_name is not None:
317
raise SyntaxError('Specify a file OR use redirection')
325
for in_name in input_names:
327
inputs.append(self._read_input(None, in_name))
329
# Some filenames are illegal on Windows and generate EINVAL
330
# rather than just saying the filename doesn't exist
331
if e.errno in (errno.ENOENT, errno.EINVAL):
333
'%s: No such file or directory\n' % (in_name,))
335
# Basically cat copy input to output
336
output = ''.join(inputs)
337
# Handle output redirections
339
output = self._write_output(output, out_name, out_mode)
341
# If out_name cannot be created, we may get 'ENOENT', however if
342
# out_name is something like '', we can get EINVAL
343
if e.errno in (errno.ENOENT, errno.EINVAL):
344
return 1, None, '%s: No such file or directory\n' % (out_name,)
346
return 0, output, None
348
def do_echo(self, test_case, input, args):
349
(in_name, out_name, out_mode, args) = _scan_redirection_options(args)
351
raise SyntaxError('echo doesn\'t read from stdin')
353
input = ' '.join(args)
354
# Always append a \n'
358
# Handle output redirections
360
output = self._write_output(output, out_name, out_mode)
362
if e.errno in (errno.ENOENT, errno.EINVAL):
363
return 1, None, '%s: No such file or directory\n' % (out_name,)
365
return 0, output, None
367
def _get_jail_root(self, test_case):
368
return test_case.test_dir
370
def _ensure_in_jail(self, test_case, path):
371
jail_root = self._get_jail_root(test_case)
372
if not osutils.is_inside(jail_root, osutils.normalizepath(path)):
373
raise ValueError('%s is not inside %s' % (path, jail_root))
375
def do_cd(self, test_case, input, args):
377
raise SyntaxError('Usage: cd [dir]')
380
self._ensure_in_jail(test_case, d)
382
# The test "home" directory is the root of its jail
383
d = self._get_jail_root(test_case)
387
def do_mkdir(self, test_case, input, args):
388
if not args or len(args) != 1:
389
raise SyntaxError('Usage: mkdir dir')
391
self._ensure_in_jail(test_case, d)
395
def do_rm(self, test_case, input, args):
398
def error(msg, path):
399
return "rm: cannot remove '%s': %s\n" % (path, msg)
401
force, recursive = False, False
403
if args and args[0][0] == '-':
404
opts = args.pop(0)[1:]
407
opts = opts.replace('f', '', 1)
410
opts = opts.replace('r', '', 1)
412
raise SyntaxError('Usage: rm [-fr] path+')
414
self._ensure_in_jail(test_case, p)
415
# FIXME: Should we put that in osutils ?
419
# Various OSes raises different exceptions (linux: EISDIR,
420
# win32: EACCES, OSX: EPERM) when invoked on a directory
421
if e.errno in (errno.EISDIR, errno.EPERM, errno.EACCES):
425
err = error('Is a directory', p)
427
elif e.errno == errno.ENOENT:
429
err = error('No such file or directory', p)
437
return retcode, None, err
439
def do_mv(self, test_case, input, args):
441
def error(msg, src, dst):
442
return "mv: cannot move %s to %s: %s\n" % (src, dst, msg)
444
if not args or len(args) != 2:
445
raise SyntaxError("Usage: mv path1 path2")
449
if os.path.isdir(dst):
450
real_dst = os.path.join(dst, os.path.basename(src))
451
os.rename(src, real_dst)
453
if e.errno == errno.ENOENT:
454
err = error('No such file or directory', src, dst)
461
return retcode, None, err
465
class TestCaseWithMemoryTransportAndScript(tests.TestCaseWithMemoryTransport):
466
"""Helper class to experiment shell-like test and memory fs.
468
This not intended to be used outside of experiments in implementing memoy
469
based file systems and evolving bzr so that test can use only memory based
474
super(TestCaseWithMemoryTransportAndScript, self).setUp()
475
self.script_runner = ScriptRunner()
477
def run_script(self, script):
478
return self.script_runner.run_script(self, script)
480
def run_command(self, cmd, input, output, error):
481
return self.script_runner.run_command(self, cmd, input, output, error)
484
class TestCaseWithTransportAndScript(tests.TestCaseWithTransport):
485
"""Helper class to quickly define shell-like tests.
489
from bzrlib.tests import script
492
class TestBug(script.TestCaseWithTransportAndScript):
494
def test_bug_nnnnn(self):
503
super(TestCaseWithTransportAndScript, self).setUp()
504
self.script_runner = ScriptRunner()
506
def run_script(self, script):
507
return self.script_runner.run_script(self, script)
509
def run_command(self, cmd, input, output, error):
510
return self.script_runner.run_command(self, cmd, input, output, error)
513
def run_script(test_case, script_string):
514
"""Run the given script within a testcase"""
515
return ScriptRunner().run_script(test_case, script_string)
518
class cmd_test_script(commands.Command):
519
"""Run a shell-like test from a file."""
522
takes_args = ['infile']
524
@commands.display_command
525
def run(self, infile):
533
class Test(TestCaseWithTransportAndScript):
535
script = None # Set before running
538
self.run_script(script)
540
runner = tests.TextTestRunner(stream=self.outf)
541
test = Test('test_it')
542
test.path = os.path.realpath(infile)
543
res = runner.run(test)
544
return len(res.errors) + len(res.failures)