1
# Copyright (C) 2009, 2010, 2011 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.
36
from ..tests import ui_testing
40
"""Split a command line respecting quotes."""
41
scanner = shlex.shlex(s)
42
scanner.quotes = '\'"`'
43
scanner.whitespace_split = True
44
for t in list(scanner):
48
def _script_to_commands(text, file_name=None):
49
"""Turn a script into a list of commands with their associated IOs.
51
Each command appears on a line by itself starting with '$ '. It can be
52
associated with an input that will feed it and an expected output.
54
Comments starts with '#' until the end of line.
55
Empty lines are ignored.
57
Input and output are full lines terminated by a '\n'.
59
Input lines start with '<'.
60
Output lines start with nothing.
61
Error lines start with '2>'.
63
:return: A sequence of ([args], input, output, errors), where the args are
64
split in to words, and the input, output, and errors are just strings,
65
typically containing newlines.
70
def add_command(cmd, input, output, error):
73
input = ''.join(input)
74
if output is not None:
75
output = ''.join(output)
77
error = ''.join(error)
78
commands.append((cmd, input, output, error))
83
input, output, error = None, None, None
84
text = textwrap.dedent(text)
85
lines = text.split('\n')
86
# to make use of triple-quoted strings easier, we ignore a blank line
87
# right at the start and right at the end; the rest are meaningful
88
if lines and lines[0] == '':
90
if lines and lines[-1] == '':
94
# Keep a copy for error reporting
96
comment = line.find('#')
99
# NB: this syntax means comments are allowed inside output, which
100
# may be confusing...
101
line = line[0:comment]
105
if line.startswith('$'):
106
# Time to output the current command
107
add_command(cmd_cur, input, output, error)
108
# And start a new one
109
cmd_cur = list(split(line[1:]))
111
input, output, error = None, None, None
112
elif line.startswith('<'):
115
raise SyntaxError('No command for that input',
116
(file_name, lineno, 1, orig))
118
input.append(line[1:] + '\n')
119
elif line.startswith('2>'):
122
raise SyntaxError('No command for that error',
123
(file_name, lineno, 1, orig))
125
error.append(line[2:] + '\n')
127
# can happen if the first line is not recognized as a command, eg
128
# if the prompt has leading whitespace
131
raise SyntaxError('No command for line %r' % (line,),
132
(file_name, lineno, 1, orig))
134
output.append(line + '\n')
135
# Add the last seen command
136
add_command(cmd_cur, input, output, error)
140
def _scan_redirection_options(args):
141
"""Recognize and process input and output redirections.
143
:param args: The command line arguments
145
:return: A tuple containing:
146
- The file name redirected from or None
147
- The file name redirected to or None
148
- The mode to open the output file or None
149
- The reamining arguments
151
def redirected_file_name(direction, name, args):
156
# We leave the error handling to higher levels, an empty name
163
out_name, out_mode = None, None
166
if arg.startswith('<'):
167
in_name = redirected_file_name('<', arg[1:], args)
168
elif arg.startswith('>>'):
169
out_name = redirected_file_name('>>', arg[2:], args)
171
elif arg.startswith('>',):
172
out_name = redirected_file_name('>', arg[1:], args)
175
remaining.append(arg)
176
return in_name, out_name, out_mode, remaining
179
class ScriptRunner(object):
180
"""Run a shell-like script from a test.
184
from breezy.tests import script
188
def test_bug_nnnnn(self):
189
sr = script.ScriptRunner()
190
sr.run_script(self, '''
198
self.output_checker = doctest.OutputChecker()
199
self.check_options = doctest.ELLIPSIS
201
def run_script(self, test_case, text, null_output_matches_anything=False):
202
"""Run a shell-like script as a test.
204
:param test_case: A TestCase instance that should provide the fail(),
205
assertEqualDiff and _run_bzr_core() methods as well as a 'test_dir'
206
attribute used as a jail root.
208
:param text: A shell-like script (see _script_to_commands for syntax).
210
:param null_output_matches_anything: For commands with no specified
211
output, ignore any output that does happen, including output on
214
self.null_output_matches_anything = null_output_matches_anything
215
for cmd, input, output, error in _script_to_commands(text):
216
self.run_command(test_case, cmd, input, output, error)
218
def run_command(self, test_case, cmd, input, output, error):
219
mname = 'do_' + cmd[0]
220
method = getattr(self, mname, None)
222
raise SyntaxError('Command not found "%s"' % (cmd[0],),
223
(None, 1, 1, ' '.join(cmd)))
227
str_input = ''.join(input)
228
args = list(self._pre_process_args(cmd[1:]))
229
retcode, actual_output, actual_error = method(test_case,
233
self._check_output(output, actual_output, test_case)
234
except AssertionError as e:
235
raise AssertionError(str(e) + " in stdout of command %s" % cmd)
237
self._check_output(error, actual_error, test_case)
238
except AssertionError as e:
239
raise AssertionError(str(e)
240
+ " in stderr of running command %s" % cmd)
241
if retcode and not error and actual_error:
242
test_case.fail('In \n\t%s\nUnexpected error: %s'
243
% (' '.join(cmd), actual_error))
244
return retcode, actual_output, actual_error
246
def _check_output(self, expected, actual, test_case):
250
elif expected == '...\n':
253
test_case.fail('expected output: %r, but found nothing'
256
null_output_matches_anything = getattr(
257
self, 'null_output_matches_anything', False)
258
if null_output_matches_anything and expected is None:
261
expected = expected or ''
262
matching = self.output_checker.check_output(
263
expected, actual, self.check_options)
265
# Note that we can't use output_checker.output_difference() here
266
# because... the API is broken ('expected' must be a doctest
267
# specific object of which a 'want' attribute will be our
268
# 'expected' parameter. So we just fallback to our good old
269
# assertEqualDiff since we know there *are* differences and the
270
# output should be decently readable.
272
# As a special case, we allow output that's missing a final
273
# newline to match an expected string that does have one, so that
274
# we can match a prompt printed on one line, then input given on
276
if expected == actual + '\n':
279
test_case.assertEqualDiff(expected, actual)
281
def _pre_process_args(self, args):
284
# Strip the simple and double quotes since we don't care about
285
# them. We leave the backquotes in place though since they have a
286
# different semantic.
287
if arg[0] in ('"', "'") and arg[0] == arg[-1]:
290
if glob.has_magic(arg):
291
matches = glob.glob(arg)
293
# We care more about order stability than performance
301
def _read_input(self, input, in_name):
302
if in_name is not None:
303
infile = open(in_name, 'r')
305
# Command redirection takes precedence over provided input
306
input = infile.read()
311
def _write_output(self, output, out_name, out_mode):
312
if out_name is not None:
313
outfile = open(out_name, out_mode)
315
outfile.write(output)
321
def do_brz(self, test_case, input, args):
322
encoding = osutils.get_user_encoding()
323
stdout = ui_testing.StringIOWithEncoding()
324
stderr = ui_testing.StringIOWithEncoding()
325
stdout.encoding = stderr.encoding = encoding
326
handler = logging.StreamHandler(stderr)
327
handler.setLevel(logging.INFO)
329
logger = logging.getLogger('')
330
logger.addHandler(handler)
332
retcode = test_case._run_bzr_core(
333
args, encoding=encoding, stdin=input, stdout=stdout,
334
stderr=stderr, working_dir=None)
336
logger.removeHandler(handler)
338
return retcode, stdout.getvalue(), stderr.getvalue()
340
def do_cat(self, test_case, input, args):
341
(in_name, out_name, out_mode, args) = _scan_redirection_options(args)
342
if args and in_name is not None:
343
raise SyntaxError('Specify a file OR use redirection')
351
for in_name in input_names:
353
inputs.append(self._read_input(None, in_name))
355
# Some filenames are illegal on Windows and generate EINVAL
356
# rather than just saying the filename doesn't exist
357
if e.errno in (errno.ENOENT, errno.EINVAL):
359
'%s: No such file or directory\n' % (in_name,))
361
# Basically cat copy input to output
362
output = ''.join(inputs)
363
# Handle output redirections
365
output = self._write_output(output, out_name, out_mode)
367
# If out_name cannot be created, we may get 'ENOENT', however if
368
# out_name is something like '', we can get EINVAL
369
if e.errno in (errno.ENOENT, errno.EINVAL):
370
return 1, None, '%s: No such file or directory\n' % (out_name,)
372
return 0, output, None
374
def do_echo(self, test_case, input, args):
375
(in_name, out_name, out_mode, args) = _scan_redirection_options(args)
377
raise SyntaxError('echo doesn\'t read from stdin')
379
input = ' '.join(args)
380
# Always append a \n'
384
# Handle output redirections
386
output = self._write_output(output, out_name, out_mode)
388
if e.errno in (errno.ENOENT, errno.EINVAL):
389
return 1, None, '%s: No such file or directory\n' % (out_name,)
391
return 0, output, None
393
def _get_jail_root(self, test_case):
394
return test_case.test_dir
396
def _ensure_in_jail(self, test_case, path):
397
jail_root = self._get_jail_root(test_case)
398
if not osutils.is_inside(jail_root, osutils.normalizepath(path)):
399
raise ValueError('%s is not inside %s' % (path, jail_root))
401
def do_cd(self, test_case, input, args):
403
raise SyntaxError('Usage: cd [dir]')
406
self._ensure_in_jail(test_case, d)
408
# The test "home" directory is the root of its jail
409
d = self._get_jail_root(test_case)
413
def do_mkdir(self, test_case, input, args):
414
if not args or len(args) != 1:
415
raise SyntaxError('Usage: mkdir dir')
417
self._ensure_in_jail(test_case, d)
421
def do_rm(self, test_case, input, args):
424
def error(msg, path):
425
return "rm: cannot remove '%s': %s\n" % (path, msg)
427
force, recursive = False, False
429
if args and args[0][0] == '-':
430
opts = args.pop(0)[1:]
433
opts = opts.replace('f', '', 1)
436
opts = opts.replace('r', '', 1)
438
raise SyntaxError('Usage: rm [-fr] path+')
440
self._ensure_in_jail(test_case, p)
441
# FIXME: Should we put that in osutils ?
445
# Various OSes raises different exceptions (linux: EISDIR,
446
# win32: EACCES, OSX: EPERM) when invoked on a directory
447
if e.errno in (errno.EISDIR, errno.EPERM, errno.EACCES):
451
err = error('Is a directory', p)
453
elif e.errno == errno.ENOENT:
455
err = error('No such file or directory', p)
463
return retcode, None, err
465
def do_mv(self, test_case, input, args):
468
def error(msg, src, dst):
469
return "mv: cannot move %s to %s: %s\n" % (src, dst, msg)
471
if not args or len(args) != 2:
472
raise SyntaxError("Usage: mv path1 path2")
476
if os.path.isdir(dst):
477
real_dst = os.path.join(dst, os.path.basename(src))
478
os.rename(src, real_dst)
480
if e.errno == errno.ENOENT:
481
err = error('No such file or directory', src, dst)
488
return retcode, None, err
491
class TestCaseWithMemoryTransportAndScript(tests.TestCaseWithMemoryTransport):
492
"""Helper class to experiment shell-like test and memory fs.
494
This not intended to be used outside of experiments in implementing memoy
495
based file systems and evolving bzr so that test can use only memory based
500
super(TestCaseWithMemoryTransportAndScript, self).setUp()
501
self.script_runner = ScriptRunner()
502
# FIXME: See shelf_ui.Shelver._char_based. This allow using shelve in
503
# scripts while providing a line-based input (better solution in
504
# progress). -- vila 2011-09-28
505
self.overrideEnv('INSIDE_EMACS', '1')
507
def run_script(self, script, null_output_matches_anything=False):
508
return self.script_runner.run_script(self, script,
509
null_output_matches_anything=null_output_matches_anything)
511
def run_command(self, cmd, input, output, error):
512
return self.script_runner.run_command(self, cmd, input, output, error)
515
class TestCaseWithTransportAndScript(tests.TestCaseWithTransport):
516
"""Helper class to quickly define shell-like tests.
520
from breezy.tests import script
523
class TestBug(script.TestCaseWithTransportAndScript):
525
def test_bug_nnnnn(self):
534
super(TestCaseWithTransportAndScript, self).setUp()
535
self.script_runner = ScriptRunner()
536
# FIXME: See shelf_ui.Shelver._char_based. This allow using shelve in
537
# scripts while providing a line-based input (better solution in
538
# progress). -- vila 2011-09-28
539
self.overrideEnv('INSIDE_EMACS', '1')
541
def run_script(self, script, null_output_matches_anything=False):
542
return self.script_runner.run_script(self, script,
543
null_output_matches_anything=null_output_matches_anything)
545
def run_command(self, cmd, input, output, error):
546
return self.script_runner.run_command(self, cmd, input, output, error)
549
def run_script(test_case, script_string, null_output_matches_anything=False):
550
"""Run the given script within a testcase"""
551
return ScriptRunner().run_script(test_case, script_string,
552
null_output_matches_anything=null_output_matches_anything)