~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/tests/script.py

  • Committer: Canonical.com Patch Queue Manager
  • Date: 2010-10-13 07:04:50 UTC
  • mfrom: (5447.2.2 work)
  • Revision ID: pqm@pqm.ubuntu.com-20101013070450-xmn9cpnli5qnmrt8
(vila) Tweak the release process based on ML discussion (Vincent Ladeuil)

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2009, 2010 Canonical Ltd
 
2
#
 
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.
 
7
#
 
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.
 
12
#
 
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
 
16
 
 
17
"""Shell-like test scripts.
 
18
 
 
19
See developers/testing.html for more explanations.
 
20
"""
 
21
 
 
22
import doctest
 
23
import errno
 
24
import glob
 
25
import os
 
26
import shlex
 
27
import textwrap
 
28
from cStringIO import StringIO
 
29
 
 
30
from bzrlib import (
 
31
    osutils,
 
32
    tests,
 
33
    )
 
34
 
 
35
 
 
36
def split(s):
 
37
    """Split a command line respecting quotes."""
 
38
    scanner = shlex.shlex(s)
 
39
    scanner.quotes = '\'"`'
 
40
    scanner.whitespace_split = True
 
41
    for t in list(scanner):
 
42
        yield t
 
43
 
 
44
 
 
45
def _script_to_commands(text, file_name=None):
 
46
    """Turn a script into a list of commands with their associated IOs.
 
47
 
 
48
    Each command appears on a line by itself starting with '$ '. It can be
 
49
    associated with an input that will feed it and an expected output.
 
50
 
 
51
    Comments starts with '#' until the end of line.
 
52
    Empty lines are ignored.
 
53
 
 
54
    Input and output are full lines terminated by a '\n'.
 
55
 
 
56
    Input lines start with '<'.
 
57
    Output lines start with nothing.
 
58
    Error lines start with '2>'.
 
59
 
 
60
    :return: A sequence of ([args], input, output, errors), where the args are
 
61
        split in to words, and the input, output, and errors are just strings,
 
62
        typically containing newlines.
 
63
    """
 
64
 
 
65
    commands = []
 
66
 
 
67
    def add_command(cmd, input, output, error):
 
68
        if cmd is not None:
 
69
            if input is not None:
 
70
                input = ''.join(input)
 
71
            if output is not None:
 
72
                output = ''.join(output)
 
73
            if error is not None:
 
74
                error = ''.join(error)
 
75
            commands.append((cmd, input, output, error))
 
76
 
 
77
    cmd_cur = None
 
78
    cmd_line = 1
 
79
    lineno = 0
 
80
    input, output, error = None, None, None
 
81
    text = textwrap.dedent(text)
 
82
    lines = text.split('\n')
 
83
    # to make use of triple-quoted strings easier, we ignore a blank line
 
84
    # right at the start and right at the end; the rest are meaningful
 
85
    if lines and lines[0] == '':
 
86
        del lines[0]
 
87
    if lines and lines[-1] == '':
 
88
        del lines[-1]
 
89
    for line in lines:
 
90
        lineno += 1
 
91
        # Keep a copy for error reporting
 
92
        orig = line
 
93
        comment =  line.find('#')
 
94
        if comment >= 0:
 
95
            # Delete comments
 
96
            # NB: this syntax means comments are allowed inside output, which
 
97
            # may be confusing...
 
98
            line = line[0:comment]
 
99
            line = line.rstrip()
 
100
            if line == '':
 
101
                continue
 
102
        if line.startswith('$'):
 
103
            # Time to output the current command
 
104
            add_command(cmd_cur, input, output, error)
 
105
            # And start a new one
 
106
            cmd_cur = list(split(line[1:]))
 
107
            cmd_line = lineno
 
108
            input, output, error = None, None, None
 
109
        elif line.startswith('<'):
 
110
            if input is None:
 
111
                if cmd_cur is None:
 
112
                    raise SyntaxError('No command for that input',
 
113
                                      (file_name, lineno, 1, orig))
 
114
                input = []
 
115
            input.append(line[1:] + '\n')
 
116
        elif line.startswith('2>'):
 
117
            if error is None:
 
118
                if cmd_cur is None:
 
119
                    raise SyntaxError('No command for that error',
 
120
                                      (file_name, lineno, 1, orig))
 
121
                error = []
 
122
            error.append(line[2:] + '\n')
 
123
        else:
 
124
            # can happen if the first line is not recognized as a command, eg
 
125
            # if the prompt has leading whitespace
 
126
            if output is None:
 
127
                if cmd_cur is None:
 
128
                    raise SyntaxError('No command for line %r' % (line,),
 
129
                                      (file_name, lineno, 1, orig))
 
130
                output = []
 
131
            output.append(line + '\n')
 
132
    # Add the last seen command
 
133
    add_command(cmd_cur, input, output, error)
 
134
    return commands
 
135
 
 
136
 
 
137
def _scan_redirection_options(args):
 
138
    """Recognize and process input and output redirections.
 
139
 
 
140
    :param args: The command line arguments
 
141
 
 
142
    :return: A tuple containing: 
 
143
        - The file name redirected from or None
 
144
        - The file name redirected to or None
 
145
        - The mode to open the output file or None
 
146
        - The reamining arguments
 
147
    """
 
148
    def redirected_file_name(direction, name, args):
 
149
        if name == '':
 
150
            try:
 
151
                name = args.pop(0)
 
152
            except IndexError:
 
153
                # We leave the error handling to higher levels, an empty name
 
154
                # can't be legal.
 
155
                name = ''
 
156
        return name
 
157
 
 
158
    remaining = []
 
159
    in_name = None
 
160
    out_name, out_mode = None, None
 
161
    while args:
 
162
        arg = args.pop(0)
 
163
        if arg.startswith('<'):
 
164
            in_name = redirected_file_name('<', arg[1:], args)
 
165
        elif arg.startswith('>>'):
 
166
            out_name = redirected_file_name('>>', arg[2:], args)
 
167
            out_mode = 'ab+'
 
168
        elif arg.startswith('>',):
 
169
            out_name = redirected_file_name('>', arg[1:], args)
 
170
            out_mode = 'wb+'
 
171
        else:
 
172
            remaining.append(arg)
 
173
    return in_name, out_name, out_mode, remaining
 
174
 
 
175
 
 
176
class ScriptRunner(object):
 
177
    """Run a shell-like script from a test.
 
178
    
 
179
    Can be used as:
 
180
 
 
181
    from bzrlib.tests import script
 
182
 
 
183
    ...
 
184
 
 
185
        def test_bug_nnnnn(self):
 
186
            sr = script.ScriptRunner()
 
187
            sr.run_script(self, '''
 
188
            $ bzr init
 
189
            $ bzr do-this
 
190
            # Boom, error
 
191
            ''')
 
192
    """
 
193
 
 
194
    def __init__(self):
 
195
        self.output_checker = doctest.OutputChecker()
 
196
        self.check_options = doctest.ELLIPSIS
 
197
 
 
198
    def run_script(self, test_case, text):
 
199
        """Run a shell-like script as a test.
 
200
 
 
201
        :param test_case: A TestCase instance that should provide the fail(),
 
202
            assertEqualDiff and _run_bzr_core() methods as well as a 'test_dir'
 
203
            attribute used as a jail root.
 
204
 
 
205
        :param text: A shell-like script (see _script_to_commands for syntax).
 
206
        """
 
207
        for cmd, input, output, error in _script_to_commands(text):
 
208
            self.run_command(test_case, cmd, input, output, error)
 
209
 
 
210
    def run_command(self, test_case, cmd, input, output, error):
 
211
        mname = 'do_' + cmd[0]
 
212
        method = getattr(self, mname, None)
 
213
        if method is None:
 
214
            raise SyntaxError('Command not found "%s"' % (cmd[0],),
 
215
                              None, 1, ' '.join(cmd))
 
216
        if input is None:
 
217
            str_input = ''
 
218
        else:
 
219
            str_input = ''.join(input)
 
220
        args = list(self._pre_process_args(cmd[1:]))
 
221
        retcode, actual_output, actual_error = method(test_case,
 
222
                                                      str_input, args)
 
223
 
 
224
        try:
 
225
            self._check_output(output, actual_output, test_case)
 
226
        except AssertionError, e:
 
227
            raise AssertionError(str(e) + " in stdout of command %s" % cmd)
 
228
        try:
 
229
            self._check_output(error, actual_error, test_case)
 
230
        except AssertionError, e:
 
231
            raise AssertionError(str(e) +
 
232
                " in stderr of running command %s" % cmd)
 
233
        if retcode and not error and actual_error:
 
234
            test_case.fail('In \n\t%s\nUnexpected error: %s'
 
235
                           % (' '.join(cmd), actual_error))
 
236
        return retcode, actual_output, actual_error
 
237
 
 
238
    def _check_output(self, expected, actual, test_case):
 
239
        if not actual:
 
240
            if expected is None:
 
241
                return
 
242
            elif expected == '...\n':
 
243
                return
 
244
            else:
 
245
                test_case.fail('expected output: %r, but found nothing'
 
246
                            % (expected,))
 
247
        expected = expected or ''
 
248
        matching = self.output_checker.check_output(
 
249
            expected, actual, self.check_options)
 
250
        if not matching:
 
251
            # Note that we can't use output_checker.output_difference() here
 
252
            # because... the API is broken ('expected' must be a doctest
 
253
            # specific object of which a 'want' attribute will be our
 
254
            # 'expected' parameter. So we just fallback to our good old
 
255
            # assertEqualDiff since we know there *are* differences and the
 
256
            # output should be decently readable.
 
257
            #
 
258
            # As a special case, we allow output that's missing a final
 
259
            # newline to match an expected string that does have one, so that
 
260
            # we can match a prompt printed on one line, then input given on
 
261
            # the next line.
 
262
            if expected == actual + '\n':
 
263
                pass
 
264
            else:
 
265
                test_case.assertEqualDiff(expected, actual)
 
266
 
 
267
    def _pre_process_args(self, args):
 
268
        new_args = []
 
269
        for arg in args:
 
270
            # Strip the simple and double quotes since we don't care about
 
271
            # them.  We leave the backquotes in place though since they have a
 
272
            # different semantic.
 
273
            if arg[0] in  ('"', "'") and arg[0] == arg[-1]:
 
274
                yield arg[1:-1]
 
275
            else:
 
276
                if glob.has_magic(arg):
 
277
                    matches = glob.glob(arg)
 
278
                    if matches:
 
279
                        # We care more about order stability than performance
 
280
                        # here
 
281
                        matches.sort()
 
282
                        for m in matches:
 
283
                            yield m
 
284
                else:
 
285
                    yield arg
 
286
 
 
287
    def _read_input(self, input, in_name):
 
288
        if in_name is not None:
 
289
            infile = open(in_name, 'rb')
 
290
            try:
 
291
                # Command redirection takes precedence over provided input
 
292
                input = infile.read()
 
293
            finally:
 
294
                infile.close()
 
295
        return input
 
296
 
 
297
    def _write_output(self, output, out_name, out_mode):
 
298
        if out_name is not None:
 
299
            outfile = open(out_name, out_mode)
 
300
            try:
 
301
                outfile.write(output)
 
302
            finally:
 
303
                outfile.close()
 
304
            output = None
 
305
        return output
 
306
 
 
307
    def do_bzr(self, test_case, input, args):
 
308
        retcode, out, err = test_case._run_bzr_core(
 
309
            args, retcode=None, encoding=None, stdin=input, working_dir=None)
 
310
        return retcode, out, err
 
311
 
 
312
    def do_cat(self, test_case, input, args):
 
313
        (in_name, out_name, out_mode, args) = _scan_redirection_options(args)
 
314
        if args and in_name is not None:
 
315
            raise SyntaxError('Specify a file OR use redirection')
 
316
 
 
317
        inputs = []
 
318
        if input:
 
319
            inputs.append(input)
 
320
        input_names = args
 
321
        if in_name:
 
322
            args.append(in_name)
 
323
        for in_name in input_names:
 
324
            try:
 
325
                inputs.append(self._read_input(None, in_name))
 
326
            except IOError, e:
 
327
                # Some filenames are illegal on Windows and generate EINVAL
 
328
                # rather than just saying the filename doesn't exist
 
329
                if e.errno in (errno.ENOENT, errno.EINVAL):
 
330
                    return (1, None,
 
331
                            '%s: No such file or directory\n' % (in_name,))
 
332
                raise
 
333
        # Basically cat copy input to output
 
334
        output = ''.join(inputs)
 
335
        # Handle output redirections
 
336
        try:
 
337
            output = self._write_output(output, out_name, out_mode)
 
338
        except IOError, e:
 
339
            # If out_name cannot be created, we may get 'ENOENT', however if
 
340
            # out_name is something like '', we can get EINVAL
 
341
            if e.errno in (errno.ENOENT, errno.EINVAL):
 
342
                return 1, None, '%s: No such file or directory\n' % (out_name,)
 
343
            raise
 
344
        return 0, output, None
 
345
 
 
346
    def do_echo(self, test_case, input, args):
 
347
        (in_name, out_name, out_mode, args) = _scan_redirection_options(args)
 
348
        if input or in_name:
 
349
            raise SyntaxError('echo doesn\'t read from stdin')
 
350
        if args:
 
351
            input = ' '.join(args)
 
352
        # Always append a \n'
 
353
        input += '\n'
 
354
        # Process output
 
355
        output = input
 
356
        # Handle output redirections
 
357
        try:
 
358
            output = self._write_output(output, out_name, out_mode)
 
359
        except IOError, e:
 
360
            if e.errno in (errno.ENOENT, errno.EINVAL):
 
361
                return 1, None, '%s: No such file or directory\n' % (out_name,)
 
362
            raise
 
363
        return 0, output, None
 
364
 
 
365
    def _get_jail_root(self, test_case):
 
366
        return test_case.test_dir
 
367
 
 
368
    def _ensure_in_jail(self, test_case, path):
 
369
        jail_root = self._get_jail_root(test_case)
 
370
        if not osutils.is_inside(jail_root, osutils.normalizepath(path)):
 
371
            raise ValueError('%s is not inside %s' % (path, jail_root))
 
372
 
 
373
    def do_cd(self, test_case, input, args):
 
374
        if len(args) > 1:
 
375
            raise SyntaxError('Usage: cd [dir]')
 
376
        if len(args) == 1:
 
377
            d = args[0]
 
378
            self._ensure_in_jail(test_case, d)
 
379
        else:
 
380
            # The test "home" directory is the root of its jail
 
381
            d = self._get_jail_root(test_case)
 
382
        os.chdir(d)
 
383
        return 0, None, None
 
384
 
 
385
    def do_mkdir(self, test_case, input, args):
 
386
        if not args or len(args) != 1:
 
387
            raise SyntaxError('Usage: mkdir dir')
 
388
        d = args[0]
 
389
        self._ensure_in_jail(test_case, d)
 
390
        os.mkdir(d)
 
391
        return 0, None, None
 
392
 
 
393
    def do_rm(self, test_case, input, args):
 
394
        err = None
 
395
 
 
396
        def error(msg, path):
 
397
            return  "rm: cannot remove '%s': %s\n" % (path, msg)
 
398
 
 
399
        force, recursive = False, False
 
400
        opts = None
 
401
        if args and args[0][0] == '-':
 
402
            opts = args.pop(0)[1:]
 
403
            if 'f' in opts:
 
404
                force = True
 
405
                opts = opts.replace('f', '', 1)
 
406
            if 'r' in opts:
 
407
                recursive = True
 
408
                opts = opts.replace('r', '', 1)
 
409
        if not args or opts:
 
410
            raise SyntaxError('Usage: rm [-fr] path+')
 
411
        for p in args:
 
412
            self._ensure_in_jail(test_case, p)
 
413
            # FIXME: Should we put that in osutils ?
 
414
            try:
 
415
                os.remove(p)
 
416
            except OSError, e:
 
417
                # Various OSes raises different exceptions (linux: EISDIR,
 
418
                #   win32: EACCES, OSX: EPERM) when invoked on a directory
 
419
                if e.errno in (errno.EISDIR, errno.EPERM, errno.EACCES):
 
420
                    if recursive:
 
421
                        osutils.rmtree(p)
 
422
                    else:
 
423
                        err = error('Is a directory', p)
 
424
                        break
 
425
                elif e.errno == errno.ENOENT:
 
426
                    if not force:
 
427
                        err =  error('No such file or directory', p)
 
428
                        break
 
429
                else:
 
430
                    raise
 
431
        if err:
 
432
            retcode = 1
 
433
        else:
 
434
            retcode = 0
 
435
        return retcode, None, err
 
436
 
 
437
    def do_mv(self, test_case, input, args):
 
438
        err = None
 
439
        def error(msg, src, dst):
 
440
            return "mv: cannot move %s to %s: %s\n" % (src, dst, msg)
 
441
 
 
442
        if not args or len(args) != 2:
 
443
            raise SyntaxError("Usage: mv path1 path2")
 
444
        src, dst = args
 
445
        try:
 
446
            real_dst = dst
 
447
            if os.path.isdir(dst):
 
448
                real_dst = os.path.join(dst, os.path.basename(src))
 
449
            os.rename(src, real_dst)
 
450
        except OSError, e:
 
451
            if e.errno == errno.ENOENT:
 
452
                err = error('No such file or directory', src, dst)
 
453
            else:
 
454
                raise
 
455
        if err:
 
456
            retcode = 1
 
457
        else:
 
458
            retcode = 0
 
459
        return retcode, None, err
 
460
 
 
461
 
 
462
 
 
463
class TestCaseWithMemoryTransportAndScript(tests.TestCaseWithMemoryTransport):
 
464
    """Helper class to experiment shell-like test and memory fs.
 
465
 
 
466
    This not intended to be used outside of experiments in implementing memoy
 
467
    based file systems and evolving bzr so that test can use only memory based
 
468
    resources.
 
469
    """
 
470
 
 
471
    def setUp(self):
 
472
        super(TestCaseWithMemoryTransportAndScript, self).setUp()
 
473
        self.script_runner = ScriptRunner()
 
474
 
 
475
    def run_script(self, script):
 
476
        return self.script_runner.run_script(self, script)
 
477
 
 
478
    def run_command(self, cmd, input, output, error):
 
479
        return self.script_runner.run_command(self, cmd, input, output, error)
 
480
 
 
481
 
 
482
class TestCaseWithTransportAndScript(tests.TestCaseWithTransport):
 
483
    """Helper class to quickly define shell-like tests.
 
484
 
 
485
    Can be used as:
 
486
 
 
487
    from bzrlib.tests import script
 
488
 
 
489
 
 
490
    class TestBug(script.TestCaseWithTransportAndScript):
 
491
 
 
492
        def test_bug_nnnnn(self):
 
493
            self.run_script('''
 
494
            $ bzr init
 
495
            $ bzr do-this
 
496
            # Boom, error
 
497
            ''')
 
498
    """
 
499
 
 
500
    def setUp(self):
 
501
        super(TestCaseWithTransportAndScript, self).setUp()
 
502
        self.script_runner = ScriptRunner()
 
503
 
 
504
    def run_script(self, script):
 
505
        return self.script_runner.run_script(self, script)
 
506
 
 
507
    def run_command(self, cmd, input, output, error):
 
508
        return self.script_runner.run_command(self, cmd, input, output, error)
 
509
 
 
510
 
 
511
def run_script(test_case, script_string):
 
512
    """Run the given script within a testcase"""
 
513
    return ScriptRunner().run_script(test_case, script_string)