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
38
"""Split a command line respecting quotes."""
39
scanner = shlex.shlex(s)
40
scanner.quotes = '\'"`'
41
scanner.whitespace_split = True
42
for t in list(scanner):
46
def _script_to_commands(text, file_name=None):
47
"""Turn a script into a list of commands with their associated IOs.
49
Each command appears on a line by itself starting with '$ '. It can be
50
associated with an input that will feed it and an expected output.
52
Comments starts with '#' until the end of line.
53
Empty lines are ignored.
55
Input and output are full lines terminated by a '\n'.
57
Input lines start with '<'.
58
Output lines start with nothing.
59
Error lines start with '2>'.
61
:return: A sequence of ([args], input, output, errors), where the args are
62
split in to words, and the input, output, and errors are just strings,
63
typically containing newlines.
68
def add_command(cmd, input, output, error):
71
input = ''.join(input)
72
if output is not None:
73
output = ''.join(output)
75
error = ''.join(error)
76
commands.append((cmd, input, output, error))
81
input, output, error = None, None, None
82
text = textwrap.dedent(text)
83
lines = text.split('\n')
84
# to make use of triple-quoted strings easier, we ignore a blank line
85
# right at the start and right at the end; the rest are meaningful
86
if lines and lines[0] == '':
88
if lines and lines[-1] == '':
92
# Keep a copy for error reporting
94
comment = line.find('#')
97
# NB: this syntax means comments are allowed inside output, which
99
line = line[0:comment]
103
if line.startswith('$'):
104
# Time to output the current command
105
add_command(cmd_cur, input, output, error)
106
# And start a new one
107
cmd_cur = list(split(line[1:]))
109
input, output, error = None, None, None
110
elif line.startswith('<'):
113
raise SyntaxError('No command for that input',
114
(file_name, lineno, 1, orig))
116
input.append(line[1:] + '\n')
117
elif line.startswith('2>'):
120
raise SyntaxError('No command for that error',
121
(file_name, lineno, 1, orig))
123
error.append(line[2:] + '\n')
125
# can happen if the first line is not recognized as a command, eg
126
# if the prompt has leading whitespace
129
raise SyntaxError('No command for line %r' % (line,),
130
(file_name, lineno, 1, orig))
132
output.append(line + '\n')
133
# Add the last seen command
134
add_command(cmd_cur, input, output, error)
138
def _scan_redirection_options(args):
139
"""Recognize and process input and output redirections.
141
:param args: The command line arguments
143
:return: A tuple containing:
144
- The file name redirected from or None
145
- The file name redirected to or None
146
- The mode to open the output file or None
147
- The reamining arguments
149
def redirected_file_name(direction, name, args):
154
# We leave the error handling to higher levels, an empty name
161
out_name, out_mode = None, None
164
if arg.startswith('<'):
165
in_name = redirected_file_name('<', arg[1:], args)
166
elif arg.startswith('>>'):
167
out_name = redirected_file_name('>>', arg[2:], args)
169
elif arg.startswith('>',):
170
out_name = redirected_file_name('>', arg[1:], args)
173
remaining.append(arg)
174
return in_name, out_name, out_mode, remaining
177
class ScriptRunner(object):
178
"""Run a shell-like script from a test.
182
from bzrlib.tests import script
186
def test_bug_nnnnn(self):
187
sr = script.ScriptRunner()
188
sr.run_script(self, '''
196
self.output_checker = doctest.OutputChecker()
197
self.check_options = doctest.ELLIPSIS
199
def run_script(self, test_case, text):
200
"""Run a shell-like script as a test.
202
:param test_case: A TestCase instance that should provide the fail(),
203
assertEqualDiff and _run_bzr_core() methods as well as a 'test_dir'
204
attribute used as a jail root.
206
:param text: A shell-like script (see _script_to_commands for syntax).
208
for cmd, input, output, error in _script_to_commands(text):
209
self.run_command(test_case, cmd, input, output, error)
211
def run_command(self, test_case, cmd, input, output, error):
212
mname = 'do_' + cmd[0]
213
method = getattr(self, mname, None)
215
raise SyntaxError('Command not found "%s"' % (cmd[0],),
216
None, 1, ' '.join(cmd))
220
str_input = ''.join(input)
221
args = list(self._pre_process_args(cmd[1:]))
222
retcode, actual_output, actual_error = method(test_case,
226
self._check_output(output, actual_output, test_case)
227
except AssertionError, e:
228
raise AssertionError(str(e) + " in stdout of command %s" % cmd)
230
self._check_output(error, actual_error, test_case)
231
except AssertionError, e:
232
raise AssertionError(str(e) +
233
" in stderr of running command %s" % cmd)
234
if retcode and not error and actual_error:
235
test_case.fail('In \n\t%s\nUnexpected error: %s'
236
% (' '.join(cmd), actual_error))
237
return retcode, actual_output, actual_error
239
def _check_output(self, expected, actual, test_case):
243
elif expected == '...\n':
246
test_case.fail('expected output: %r, but found nothing'
248
expected = expected or ''
249
matching = self.output_checker.check_output(
250
expected, actual, self.check_options)
252
# Note that we can't use output_checker.output_difference() here
253
# because... the API is broken ('expected' must be a doctest
254
# specific object of which a 'want' attribute will be our
255
# 'expected' parameter. So we just fallback to our good old
256
# assertEqualDiff since we know there *are* differences and the
257
# output should be decently readable.
259
# As a special case, we allow output that's missing a final
260
# newline to match an expected string that does have one, so that
261
# we can match a prompt printed on one line, then input given on
263
if expected == actual + '\n':
266
test_case.assertEqualDiff(expected, actual)
268
def _pre_process_args(self, args):
271
# Strip the simple and double quotes since we don't care about
272
# them. We leave the backquotes in place though since they have a
273
# different semantic.
274
if arg[0] in ('"', "'") and arg[0] == arg[-1]:
277
if glob.has_magic(arg):
278
matches = glob.glob(arg)
280
# We care more about order stability than performance
288
def _read_input(self, input, in_name):
289
if in_name is not None:
290
infile = open(in_name, 'rb')
292
# Command redirection takes precedence over provided input
293
input = infile.read()
298
def _write_output(self, output, out_name, out_mode):
299
if out_name is not None:
300
outfile = open(out_name, out_mode)
302
outfile.write(output)
308
def do_bzr(self, test_case, input, args):
309
retcode, out, err = test_case._run_bzr_core(
310
args, retcode=None, encoding=None, stdin=input, working_dir=None)
311
return retcode, out, err
313
def do_cat(self, test_case, input, args):
314
(in_name, out_name, out_mode, args) = _scan_redirection_options(args)
315
if args and in_name is not None:
316
raise SyntaxError('Specify a file OR use redirection')
324
for in_name in input_names:
326
inputs.append(self._read_input(None, in_name))
328
# Some filenames are illegal on Windows and generate EINVAL
329
# rather than just saying the filename doesn't exist
330
if e.errno in (errno.ENOENT, errno.EINVAL):
332
'%s: No such file or directory\n' % (in_name,))
334
# Basically cat copy input to output
335
output = ''.join(inputs)
336
# Handle output redirections
338
output = self._write_output(output, out_name, out_mode)
340
# If out_name cannot be created, we may get 'ENOENT', however if
341
# out_name is something like '', we can get EINVAL
342
if e.errno in (errno.ENOENT, errno.EINVAL):
343
return 1, None, '%s: No such file or directory\n' % (out_name,)
345
return 0, output, None
347
def do_echo(self, test_case, input, args):
348
(in_name, out_name, out_mode, args) = _scan_redirection_options(args)
350
raise SyntaxError('echo doesn\'t read from stdin')
352
input = ' '.join(args)
353
# Always append a \n'
357
# Handle output redirections
359
output = self._write_output(output, out_name, out_mode)
361
if e.errno in (errno.ENOENT, errno.EINVAL):
362
return 1, None, '%s: No such file or directory\n' % (out_name,)
364
return 0, output, None
366
def _get_jail_root(self, test_case):
367
return test_case.test_dir
369
def _ensure_in_jail(self, test_case, path):
370
jail_root = self._get_jail_root(test_case)
371
if not osutils.is_inside(jail_root, osutils.normalizepath(path)):
372
raise ValueError('%s is not inside %s' % (path, jail_root))
374
def do_cd(self, test_case, input, args):
376
raise SyntaxError('Usage: cd [dir]')
379
self._ensure_in_jail(test_case, d)
381
# The test "home" directory is the root of its jail
382
d = self._get_jail_root(test_case)
386
def do_mkdir(self, test_case, input, args):
387
if not args or len(args) != 1:
388
raise SyntaxError('Usage: mkdir dir')
390
self._ensure_in_jail(test_case, d)
394
def do_rm(self, test_case, input, args):
397
def error(msg, path):
398
return "rm: cannot remove '%s': %s\n" % (path, msg)
400
force, recursive = False, False
402
if args and args[0][0] == '-':
403
opts = args.pop(0)[1:]
406
opts = opts.replace('f', '', 1)
409
opts = opts.replace('r', '', 1)
411
raise SyntaxError('Usage: rm [-fr] path+')
413
self._ensure_in_jail(test_case, p)
414
# FIXME: Should we put that in osutils ?
418
# Various OSes raises different exceptions (linux: EISDIR,
419
# win32: EACCES, OSX: EPERM) when invoked on a directory
420
if e.errno in (errno.EISDIR, errno.EPERM, errno.EACCES):
424
err = error('Is a directory', p)
426
elif e.errno == errno.ENOENT:
428
err = error('No such file or directory', p)
436
return retcode, None, err
438
def do_mv(self, test_case, input, args):
440
def error(msg, src, dst):
441
return "mv: cannot move %s to %s: %s\n" % (src, dst, msg)
443
if not args or len(args) != 2:
444
raise SyntaxError("Usage: mv path1 path2")
448
if os.path.isdir(dst):
449
real_dst = os.path.join(dst, os.path.basename(src))
450
os.rename(src, real_dst)
452
if e.errno == errno.ENOENT:
453
err = error('No such file or directory', src, dst)
460
return retcode, None, err
464
class TestCaseWithMemoryTransportAndScript(tests.TestCaseWithMemoryTransport):
465
"""Helper class to experiment shell-like test and memory fs.
467
This not intended to be used outside of experiments in implementing memoy
468
based file systems and evolving bzr so that test can use only memory based
473
super(TestCaseWithMemoryTransportAndScript, self).setUp()
474
self.script_runner = ScriptRunner()
476
def run_script(self, script):
477
return self.script_runner.run_script(self, script)
479
def run_command(self, cmd, input, output, error):
480
return self.script_runner.run_command(self, cmd, input, output, error)
483
class TestCaseWithTransportAndScript(tests.TestCaseWithTransport):
484
"""Helper class to quickly define shell-like tests.
488
from bzrlib.tests import script
491
class TestBug(script.TestCaseWithTransportAndScript):
493
def test_bug_nnnnn(self):
502
super(TestCaseWithTransportAndScript, self).setUp()
503
self.script_runner = ScriptRunner()
505
def run_script(self, script):
506
return self.script_runner.run_script(self, script)
508
def run_command(self, cmd, input, output, error):
509
return self.script_runner.run_command(self, cmd, input, output, error)
512
def run_script(test_case, script_string):
513
"""Run the given script within a testcase"""
514
return ScriptRunner().run_script(test_case, script_string)