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
if sys.version_info[0] == 2:
324
stdout = ui_testing.BytesIOWithEncoding()
325
stderr = ui_testing.BytesIOWithEncoding()
326
stdout.encoding = stderr.encoding = encoding
328
# FIXME: don't call into logging here
329
handler = trace.EncodedStreamHandler(
330
stderr, errors="replace")
332
stdout = ui_testing.StringIOWithEncoding()
333
stderr = ui_testing.StringIOWithEncoding()
334
stdout.encoding = stderr.encoding = encoding
335
handler = logging.StreamHandler(stderr)
336
handler.setLevel(logging.INFO)
338
logger = logging.getLogger('')
339
logger.addHandler(handler)
341
retcode = test_case._run_bzr_core(
342
args, encoding=encoding, stdin=input, stdout=stdout,
343
stderr=stderr, working_dir=None)
345
logger.removeHandler(handler)
347
return retcode, stdout.getvalue(), stderr.getvalue()
349
def do_cat(self, test_case, input, args):
350
(in_name, out_name, out_mode, args) = _scan_redirection_options(args)
351
if args and in_name is not None:
352
raise SyntaxError('Specify a file OR use redirection')
360
for in_name in input_names:
362
inputs.append(self._read_input(None, in_name))
364
# Some filenames are illegal on Windows and generate EINVAL
365
# rather than just saying the filename doesn't exist
366
if e.errno in (errno.ENOENT, errno.EINVAL):
368
'%s: No such file or directory\n' % (in_name,))
370
# Basically cat copy input to output
371
output = ''.join(inputs)
372
# Handle output redirections
374
output = self._write_output(output, out_name, out_mode)
376
# If out_name cannot be created, we may get 'ENOENT', however if
377
# out_name is something like '', we can get EINVAL
378
if e.errno in (errno.ENOENT, errno.EINVAL):
379
return 1, None, '%s: No such file or directory\n' % (out_name,)
381
return 0, output, None
383
def do_echo(self, test_case, input, args):
384
(in_name, out_name, out_mode, args) = _scan_redirection_options(args)
386
raise SyntaxError('echo doesn\'t read from stdin')
388
input = ' '.join(args)
389
# Always append a \n'
393
# Handle output redirections
395
output = self._write_output(output, out_name, out_mode)
397
if e.errno in (errno.ENOENT, errno.EINVAL):
398
return 1, None, '%s: No such file or directory\n' % (out_name,)
400
return 0, output, None
402
def _get_jail_root(self, test_case):
403
return test_case.test_dir
405
def _ensure_in_jail(self, test_case, path):
406
jail_root = self._get_jail_root(test_case)
407
if not osutils.is_inside(jail_root, osutils.normalizepath(path)):
408
raise ValueError('%s is not inside %s' % (path, jail_root))
410
def do_cd(self, test_case, input, args):
412
raise SyntaxError('Usage: cd [dir]')
415
self._ensure_in_jail(test_case, d)
417
# The test "home" directory is the root of its jail
418
d = self._get_jail_root(test_case)
422
def do_mkdir(self, test_case, input, args):
423
if not args or len(args) != 1:
424
raise SyntaxError('Usage: mkdir dir')
426
self._ensure_in_jail(test_case, d)
430
def do_rm(self, test_case, input, args):
433
def error(msg, path):
434
return "rm: cannot remove '%s': %s\n" % (path, msg)
436
force, recursive = False, False
438
if args and args[0][0] == '-':
439
opts = args.pop(0)[1:]
442
opts = opts.replace('f', '', 1)
445
opts = opts.replace('r', '', 1)
447
raise SyntaxError('Usage: rm [-fr] path+')
449
self._ensure_in_jail(test_case, p)
450
# FIXME: Should we put that in osutils ?
454
# Various OSes raises different exceptions (linux: EISDIR,
455
# win32: EACCES, OSX: EPERM) when invoked on a directory
456
if e.errno in (errno.EISDIR, errno.EPERM, errno.EACCES):
460
err = error('Is a directory', p)
462
elif e.errno == errno.ENOENT:
464
err = error('No such file or directory', p)
472
return retcode, None, err
474
def do_mv(self, test_case, input, args):
477
def error(msg, src, dst):
478
return "mv: cannot move %s to %s: %s\n" % (src, dst, msg)
480
if not args or len(args) != 2:
481
raise SyntaxError("Usage: mv path1 path2")
485
if os.path.isdir(dst):
486
real_dst = os.path.join(dst, os.path.basename(src))
487
os.rename(src, real_dst)
489
if e.errno == errno.ENOENT:
490
err = error('No such file or directory', src, dst)
497
return retcode, None, err
500
class TestCaseWithMemoryTransportAndScript(tests.TestCaseWithMemoryTransport):
501
"""Helper class to experiment shell-like test and memory fs.
503
This not intended to be used outside of experiments in implementing memoy
504
based file systems and evolving bzr so that test can use only memory based
509
super(TestCaseWithMemoryTransportAndScript, self).setUp()
510
self.script_runner = ScriptRunner()
511
# FIXME: See shelf_ui.Shelver._char_based. This allow using shelve in
512
# scripts while providing a line-based input (better solution in
513
# progress). -- vila 2011-09-28
514
self.overrideEnv('INSIDE_EMACS', '1')
516
def run_script(self, script, null_output_matches_anything=False):
517
return self.script_runner.run_script(self, script,
518
null_output_matches_anything=null_output_matches_anything)
520
def run_command(self, cmd, input, output, error):
521
return self.script_runner.run_command(self, cmd, input, output, error)
524
class TestCaseWithTransportAndScript(tests.TestCaseWithTransport):
525
"""Helper class to quickly define shell-like tests.
529
from breezy.tests import script
532
class TestBug(script.TestCaseWithTransportAndScript):
534
def test_bug_nnnnn(self):
543
super(TestCaseWithTransportAndScript, self).setUp()
544
self.script_runner = ScriptRunner()
545
# FIXME: See shelf_ui.Shelver._char_based. This allow using shelve in
546
# scripts while providing a line-based input (better solution in
547
# progress). -- vila 2011-09-28
548
self.overrideEnv('INSIDE_EMACS', '1')
550
def run_script(self, script, null_output_matches_anything=False):
551
return self.script_runner.run_script(self, script,
552
null_output_matches_anything=null_output_matches_anything)
554
def run_command(self, cmd, input, output, error):
555
return self.script_runner.run_command(self, cmd, input, output, error)
558
def run_script(test_case, script_string, null_output_matches_anything=False):
559
"""Run the given script within a testcase"""
560
return ScriptRunner().run_script(test_case, script_string,
561
null_output_matches_anything=null_output_matches_anything)