~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-18 11:57:18 UTC
  • mfrom: (5505.1.1 trunk)
  • Revision ID: pqm@pqm.ubuntu.com-20101018115718-cbuoc2gafnjldngk
(vila) Document hunk editing when shelving.(Neil Martinsen-Burrell)

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
    commands,
 
32
    errors,
 
33
    osutils,
 
34
    tests,
 
35
    )
 
36
 
 
37
 
 
38
def split(s):
 
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):
 
44
        yield t
 
45
 
 
46
 
 
47
def _script_to_commands(text, file_name=None):
 
48
    """Turn a script into a list of commands with their associated IOs.
 
49
 
 
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.
 
52
 
 
53
    Comments starts with '#' until the end of line.
 
54
    Empty lines are ignored.
 
55
 
 
56
    Input and output are full lines terminated by a '\n'.
 
57
 
 
58
    Input lines start with '<'.
 
59
    Output lines start with nothing.
 
60
    Error lines start with '2>'.
 
61
 
 
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.
 
65
    """
 
66
 
 
67
    commands = []
 
68
 
 
69
    def add_command(cmd, input, output, error):
 
70
        if cmd is not None:
 
71
            if input is not None:
 
72
                input = ''.join(input)
 
73
            if output is not None:
 
74
                output = ''.join(output)
 
75
            if error is not None:
 
76
                error = ''.join(error)
 
77
            commands.append((cmd, input, output, error))
 
78
 
 
79
    cmd_cur = None
 
80
    cmd_line = 1
 
81
    lineno = 0
 
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] == '':
 
88
        del lines[0]
 
89
    if lines and lines[-1] == '':
 
90
        del lines[-1]
 
91
    for line in lines:
 
92
        lineno += 1
 
93
        # Keep a copy for error reporting
 
94
        orig = line
 
95
        comment =  line.find('#')
 
96
        if comment >= 0:
 
97
            # Delete comments
 
98
            # NB: this syntax means comments are allowed inside output, which
 
99
            # may be confusing...
 
100
            line = line[0:comment]
 
101
            line = line.rstrip()
 
102
            if line == '':
 
103
                continue
 
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:]))
 
109
            cmd_line = lineno
 
110
            input, output, error = None, None, None
 
111
        elif line.startswith('<'):
 
112
            if input is None:
 
113
                if cmd_cur is None:
 
114
                    raise SyntaxError('No command for that input',
 
115
                                      (file_name, lineno, 1, orig))
 
116
                input = []
 
117
            input.append(line[1:] + '\n')
 
118
        elif line.startswith('2>'):
 
119
            if error is None:
 
120
                if cmd_cur is None:
 
121
                    raise SyntaxError('No command for that error',
 
122
                                      (file_name, lineno, 1, orig))
 
123
                error = []
 
124
            error.append(line[2:] + '\n')
 
125
        else:
 
126
            # can happen if the first line is not recognized as a command, eg
 
127
            # if the prompt has leading whitespace
 
128
            if output is None:
 
129
                if cmd_cur is None:
 
130
                    raise SyntaxError('No command for line %r' % (line,),
 
131
                                      (file_name, lineno, 1, orig))
 
132
                output = []
 
133
            output.append(line + '\n')
 
134
    # Add the last seen command
 
135
    add_command(cmd_cur, input, output, error)
 
136
    return commands
 
137
 
 
138
 
 
139
def _scan_redirection_options(args):
 
140
    """Recognize and process input and output redirections.
 
141
 
 
142
    :param args: The command line arguments
 
143
 
 
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
 
149
    """
 
150
    def redirected_file_name(direction, name, args):
 
151
        if name == '':
 
152
            try:
 
153
                name = args.pop(0)
 
154
            except IndexError:
 
155
                # We leave the error handling to higher levels, an empty name
 
156
                # can't be legal.
 
157
                name = ''
 
158
        return name
 
159
 
 
160
    remaining = []
 
161
    in_name = None
 
162
    out_name, out_mode = None, None
 
163
    while args:
 
164
        arg = args.pop(0)
 
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)
 
169
            out_mode = 'ab+'
 
170
        elif arg.startswith('>',):
 
171
            out_name = redirected_file_name('>', arg[1:], args)
 
172
            out_mode = 'wb+'
 
173
        else:
 
174
            remaining.append(arg)
 
175
    return in_name, out_name, out_mode, remaining
 
176
 
 
177
 
 
178
class ScriptRunner(object):
 
179
    """Run a shell-like script from a test.
 
180
    
 
181
    Can be used as:
 
182
 
 
183
    from bzrlib.tests import script
 
184
 
 
185
    ...
 
186
 
 
187
        def test_bug_nnnnn(self):
 
188
            sr = script.ScriptRunner()
 
189
            sr.run_script(self, '''
 
190
            $ bzr init
 
191
            $ bzr do-this
 
192
            # Boom, error
 
193
            ''')
 
194
    """
 
195
 
 
196
    def __init__(self):
 
197
        self.output_checker = doctest.OutputChecker()
 
198
        self.check_options = doctest.ELLIPSIS
 
199
 
 
200
    def run_script(self, test_case, text):
 
201
        """Run a shell-like script as a test.
 
202
 
 
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.
 
206
 
 
207
        :param text: A shell-like script (see _script_to_commands for syntax).
 
208
        """
 
209
        for cmd, input, output, error in _script_to_commands(text):
 
210
            self.run_command(test_case, cmd, input, output, error)
 
211
 
 
212
    def run_command(self, test_case, cmd, input, output, error):
 
213
        mname = 'do_' + cmd[0]
 
214
        method = getattr(self, mname, None)
 
215
        if method is None:
 
216
            raise SyntaxError('Command not found "%s"' % (cmd[0],),
 
217
                              None, 1, ' '.join(cmd))
 
218
        if input is None:
 
219
            str_input = ''
 
220
        else:
 
221
            str_input = ''.join(input)
 
222
        args = list(self._pre_process_args(cmd[1:]))
 
223
        retcode, actual_output, actual_error = method(test_case,
 
224
                                                      str_input, args)
 
225
 
 
226
        try:
 
227
            self._check_output(output, actual_output, test_case)
 
228
        except AssertionError, e:
 
229
            raise AssertionError(str(e) + " in stdout of command %s" % cmd)
 
230
        try:
 
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
 
239
 
 
240
    def _check_output(self, expected, actual, test_case):
 
241
        if not actual:
 
242
            if expected is None:
 
243
                return
 
244
            elif expected == '...\n':
 
245
                return
 
246
            else:
 
247
                test_case.fail('expected output: %r, but found nothing'
 
248
                            % (expected,))
 
249
        expected = expected or ''
 
250
        matching = self.output_checker.check_output(
 
251
            expected, actual, self.check_options)
 
252
        if not matching:
 
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.
 
259
            #
 
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
 
263
            # the next line.
 
264
            if expected == actual + '\n':
 
265
                pass
 
266
            else:
 
267
                test_case.assertEqualDiff(expected, actual)
 
268
 
 
269
    def _pre_process_args(self, args):
 
270
        new_args = []
 
271
        for arg in 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]:
 
276
                yield arg[1:-1]
 
277
            else:
 
278
                if glob.has_magic(arg):
 
279
                    matches = glob.glob(arg)
 
280
                    if matches:
 
281
                        # We care more about order stability than performance
 
282
                        # here
 
283
                        matches.sort()
 
284
                        for m in matches:
 
285
                            yield m
 
286
                else:
 
287
                    yield arg
 
288
 
 
289
    def _read_input(self, input, in_name):
 
290
        if in_name is not None:
 
291
            infile = open(in_name, 'rb')
 
292
            try:
 
293
                # Command redirection takes precedence over provided input
 
294
                input = infile.read()
 
295
            finally:
 
296
                infile.close()
 
297
        return input
 
298
 
 
299
    def _write_output(self, output, out_name, out_mode):
 
300
        if out_name is not None:
 
301
            outfile = open(out_name, out_mode)
 
302
            try:
 
303
                outfile.write(output)
 
304
            finally:
 
305
                outfile.close()
 
306
            output = None
 
307
        return output
 
308
 
 
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
 
313
 
 
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')
 
318
 
 
319
        inputs = []
 
320
        if input:
 
321
            inputs.append(input)
 
322
        input_names = args
 
323
        if in_name:
 
324
            args.append(in_name)
 
325
        for in_name in input_names:
 
326
            try:
 
327
                inputs.append(self._read_input(None, in_name))
 
328
            except IOError, e:
 
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):
 
332
                    return (1, None,
 
333
                            '%s: No such file or directory\n' % (in_name,))
 
334
                raise
 
335
        # Basically cat copy input to output
 
336
        output = ''.join(inputs)
 
337
        # Handle output redirections
 
338
        try:
 
339
            output = self._write_output(output, out_name, out_mode)
 
340
        except IOError, e:
 
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,)
 
345
            raise
 
346
        return 0, output, None
 
347
 
 
348
    def do_echo(self, test_case, input, args):
 
349
        (in_name, out_name, out_mode, args) = _scan_redirection_options(args)
 
350
        if input or in_name:
 
351
            raise SyntaxError('echo doesn\'t read from stdin')
 
352
        if args:
 
353
            input = ' '.join(args)
 
354
        # Always append a \n'
 
355
        input += '\n'
 
356
        # Process output
 
357
        output = input
 
358
        # Handle output redirections
 
359
        try:
 
360
            output = self._write_output(output, out_name, out_mode)
 
361
        except IOError, e:
 
362
            if e.errno in (errno.ENOENT, errno.EINVAL):
 
363
                return 1, None, '%s: No such file or directory\n' % (out_name,)
 
364
            raise
 
365
        return 0, output, None
 
366
 
 
367
    def _get_jail_root(self, test_case):
 
368
        return test_case.test_dir
 
369
 
 
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))
 
374
 
 
375
    def do_cd(self, test_case, input, args):
 
376
        if len(args) > 1:
 
377
            raise SyntaxError('Usage: cd [dir]')
 
378
        if len(args) == 1:
 
379
            d = args[0]
 
380
            self._ensure_in_jail(test_case, d)
 
381
        else:
 
382
            # The test "home" directory is the root of its jail
 
383
            d = self._get_jail_root(test_case)
 
384
        os.chdir(d)
 
385
        return 0, None, None
 
386
 
 
387
    def do_mkdir(self, test_case, input, args):
 
388
        if not args or len(args) != 1:
 
389
            raise SyntaxError('Usage: mkdir dir')
 
390
        d = args[0]
 
391
        self._ensure_in_jail(test_case, d)
 
392
        os.mkdir(d)
 
393
        return 0, None, None
 
394
 
 
395
    def do_rm(self, test_case, input, args):
 
396
        err = None
 
397
 
 
398
        def error(msg, path):
 
399
            return  "rm: cannot remove '%s': %s\n" % (path, msg)
 
400
 
 
401
        force, recursive = False, False
 
402
        opts = None
 
403
        if args and args[0][0] == '-':
 
404
            opts = args.pop(0)[1:]
 
405
            if 'f' in opts:
 
406
                force = True
 
407
                opts = opts.replace('f', '', 1)
 
408
            if 'r' in opts:
 
409
                recursive = True
 
410
                opts = opts.replace('r', '', 1)
 
411
        if not args or opts:
 
412
            raise SyntaxError('Usage: rm [-fr] path+')
 
413
        for p in args:
 
414
            self._ensure_in_jail(test_case, p)
 
415
            # FIXME: Should we put that in osutils ?
 
416
            try:
 
417
                os.remove(p)
 
418
            except OSError, e:
 
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):
 
422
                    if recursive:
 
423
                        osutils.rmtree(p)
 
424
                    else:
 
425
                        err = error('Is a directory', p)
 
426
                        break
 
427
                elif e.errno == errno.ENOENT:
 
428
                    if not force:
 
429
                        err =  error('No such file or directory', p)
 
430
                        break
 
431
                else:
 
432
                    raise
 
433
        if err:
 
434
            retcode = 1
 
435
        else:
 
436
            retcode = 0
 
437
        return retcode, None, err
 
438
 
 
439
    def do_mv(self, test_case, input, args):
 
440
        err = None
 
441
        def error(msg, src, dst):
 
442
            return "mv: cannot move %s to %s: %s\n" % (src, dst, msg)
 
443
 
 
444
        if not args or len(args) != 2:
 
445
            raise SyntaxError("Usage: mv path1 path2")
 
446
        src, dst = args
 
447
        try:
 
448
            real_dst = dst
 
449
            if os.path.isdir(dst):
 
450
                real_dst = os.path.join(dst, os.path.basename(src))
 
451
            os.rename(src, real_dst)
 
452
        except OSError, e:
 
453
            if e.errno == errno.ENOENT:
 
454
                err = error('No such file or directory', src, dst)
 
455
            else:
 
456
                raise
 
457
        if err:
 
458
            retcode = 1
 
459
        else:
 
460
            retcode = 0
 
461
        return retcode, None, err
 
462
 
 
463
 
 
464
 
 
465
class TestCaseWithMemoryTransportAndScript(tests.TestCaseWithMemoryTransport):
 
466
    """Helper class to experiment shell-like test and memory fs.
 
467
 
 
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
 
470
    resources.
 
471
    """
 
472
 
 
473
    def setUp(self):
 
474
        super(TestCaseWithMemoryTransportAndScript, self).setUp()
 
475
        self.script_runner = ScriptRunner()
 
476
 
 
477
    def run_script(self, script):
 
478
        return self.script_runner.run_script(self, script)
 
479
 
 
480
    def run_command(self, cmd, input, output, error):
 
481
        return self.script_runner.run_command(self, cmd, input, output, error)
 
482
 
 
483
 
 
484
class TestCaseWithTransportAndScript(tests.TestCaseWithTransport):
 
485
    """Helper class to quickly define shell-like tests.
 
486
 
 
487
    Can be used as:
 
488
 
 
489
    from bzrlib.tests import script
 
490
 
 
491
 
 
492
    class TestBug(script.TestCaseWithTransportAndScript):
 
493
 
 
494
        def test_bug_nnnnn(self):
 
495
            self.run_script('''
 
496
            $ bzr init
 
497
            $ bzr do-this
 
498
            # Boom, error
 
499
            ''')
 
500
    """
 
501
 
 
502
    def setUp(self):
 
503
        super(TestCaseWithTransportAndScript, self).setUp()
 
504
        self.script_runner = ScriptRunner()
 
505
 
 
506
    def run_script(self, script):
 
507
        return self.script_runner.run_script(self, script)
 
508
 
 
509
    def run_command(self, cmd, input, output, error):
 
510
        return self.script_runner.run_command(self, cmd, input, output, error)
 
511
 
 
512
 
 
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)
 
516
 
 
517
 
 
518
class cmd_test_script(commands.Command):
 
519
    """Run a shell-like test from a file."""
 
520
 
 
521
    hidden = True
 
522
    takes_args = ['infile']
 
523
 
 
524
    @commands.display_command
 
525
    def run(self, infile):
 
526
 
 
527
        f = open(infile)
 
528
        try:
 
529
            script = f.read()
 
530
        finally:
 
531
            f.close()
 
532
 
 
533
        class Test(TestCaseWithTransportAndScript):
 
534
 
 
535
            script = None # Set before running
 
536
 
 
537
            def test_it(self):
 
538
                self.run_script(script)
 
539
 
 
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)