~bzr-pqm/bzr/bzr.dev

« back to all changes in this revision

Viewing changes to bzrlib/tests/test_source.py

  • Committer: Jelmer Vernooij
  • Date: 2010-03-13 02:49:14 UTC
  • mto: This revision was merged to the branch mainline in revision 5089.
  • Revision ID: jelmer@samba.org-20100313024914-rpuoguinoxpxt05b
Allow merge directives to output multiple patch files. 

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2005-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
"""These tests are tests about the source code of bzrlib itself.
 
18
 
 
19
They are useful for testing code quality, checking coverage metric etc.
 
20
"""
 
21
 
 
22
# import system imports here
 
23
import os
 
24
import parser
 
25
import re
 
26
import symbol
 
27
import sys
 
28
import token
 
29
 
 
30
#import bzrlib specific imports here
 
31
from bzrlib import (
 
32
    osutils,
 
33
    )
 
34
import bzrlib.branch
 
35
from bzrlib.tests import (
 
36
    TestCase,
 
37
    TestSkipped,
 
38
    )
 
39
 
 
40
 
 
41
# Files which are listed here will be skipped when testing for Copyright (or
 
42
# GPL) statements.
 
43
COPYRIGHT_EXCEPTIONS = ['bzrlib/lsprof.py', 'bzrlib/_bencode_py.py',
 
44
    'bzrlib/doc_generate/sphinx_conf.py']
 
45
 
 
46
LICENSE_EXCEPTIONS = ['bzrlib/lsprof.py', 'bzrlib/_bencode_py.py',
 
47
    'bzrlib/doc_generate/sphinx_conf.py']
 
48
# Technically, 'bzrlib/lsprof.py' should be 'bzrlib/util/lsprof.py',
 
49
# (we do not check bzrlib/util/, since that is code bundled from elsewhere)
 
50
# but for compatibility with previous releases, we don't want to move it.
 
51
#
 
52
# sphinx_conf is semi-autogenerated.
 
53
 
 
54
 
 
55
class TestSourceHelper(TestCase):
 
56
 
 
57
    def source_file_name(self, package):
 
58
        """Return the path of the .py file for package."""
 
59
        if getattr(sys, "frozen", None) is not None:
 
60
            raise TestSkipped("can't test sources in frozen distributions.")
 
61
        path = package.__file__
 
62
        if path[-1] in 'co':
 
63
            return path[:-1]
 
64
        else:
 
65
            return path
 
66
 
 
67
 
 
68
class TestApiUsage(TestSourceHelper):
 
69
 
 
70
    def find_occurences(self, rule, filename):
 
71
        """Find the number of occurences of rule in a file."""
 
72
        occurences = 0
 
73
        source = file(filename, 'r')
 
74
        for line in source:
 
75
            if line.find(rule) > -1:
 
76
                occurences += 1
 
77
        return occurences
 
78
 
 
79
    def test_branch_working_tree(self):
 
80
        """Test that the number of uses of working_tree in branch is stable."""
 
81
        occurences = self.find_occurences('self.working_tree()',
 
82
                                          self.source_file_name(bzrlib.branch))
 
83
        # do not even think of increasing this number. If you think you need to
 
84
        # increase it, then you almost certainly are doing something wrong as
 
85
        # the relationship from working_tree to branch is one way.
 
86
        # Note that this is an exact equality so that when the number drops,
 
87
        #it is not given a buffer but rather has this test updated immediately.
 
88
        self.assertEqual(0, occurences)
 
89
 
 
90
    def test_branch_WorkingTree(self):
 
91
        """Test that the number of uses of working_tree in branch is stable."""
 
92
        occurences = self.find_occurences('WorkingTree',
 
93
                                          self.source_file_name(bzrlib.branch))
 
94
        # Do not even think of increasing this number. If you think you need to
 
95
        # increase it, then you almost certainly are doing something wrong as
 
96
        # the relationship from working_tree to branch is one way.
 
97
        # As of 20070809, there are no longer any mentions at all.
 
98
        self.assertEqual(0, occurences)
 
99
 
 
100
 
 
101
class TestSource(TestSourceHelper):
 
102
 
 
103
    def get_bzrlib_dir(self):
 
104
        """Get the path to the root of bzrlib"""
 
105
        source = self.source_file_name(bzrlib)
 
106
        source_dir = os.path.dirname(source)
 
107
 
 
108
        # Avoid the case when bzrlib is packaged in a zip file
 
109
        if not os.path.isdir(source_dir):
 
110
            raise TestSkipped('Cannot find bzrlib source directory. Expected %s'
 
111
                              % source_dir)
 
112
        return source_dir
 
113
 
 
114
    def get_source_files(self, extensions=None):
 
115
        """Yield all source files for bzr and bzrlib
 
116
 
 
117
        :param our_files_only: If true, exclude files from included libraries
 
118
            or plugins.
 
119
        """
 
120
        bzrlib_dir = self.get_bzrlib_dir()
 
121
        if extensions is None:
 
122
            extensions = ('.py',)
 
123
 
 
124
        # This is the front-end 'bzr' script
 
125
        bzr_path = self.get_bzr_path()
 
126
        yield bzr_path
 
127
 
 
128
        for root, dirs, files in os.walk(bzrlib_dir):
 
129
            for d in dirs:
 
130
                if d.endswith('.tmp'):
 
131
                    dirs.remove(d)
 
132
            for f in files:
 
133
                for extension in extensions:
 
134
                    if f.endswith(extension):
 
135
                        break
 
136
                else:
 
137
                    # Did not match the accepted extensions
 
138
                    continue
 
139
                yield osutils.pathjoin(root, f)
 
140
 
 
141
    def get_source_file_contents(self, extensions=None):
 
142
        for fname in self.get_source_files(extensions=extensions):
 
143
            f = open(fname, 'rb')
 
144
            try:
 
145
                text = f.read()
 
146
            finally:
 
147
                f.close()
 
148
            yield fname, text
 
149
 
 
150
    def is_our_code(self, fname):
 
151
        """Return true if it's a "real" part of bzrlib rather than external code"""
 
152
        if '/util/' in fname or '/plugins/' in fname:
 
153
            return False
 
154
        else:
 
155
            return True
 
156
 
 
157
    def is_copyright_exception(self, fname):
 
158
        """Certain files are allowed to be different"""
 
159
        if not self.is_our_code(fname):
 
160
            # We don't ask that external utilities or plugins be
 
161
            # (C) Canonical Ltd
 
162
            return True
 
163
        for exc in COPYRIGHT_EXCEPTIONS:
 
164
            if fname.endswith(exc):
 
165
                return True
 
166
        return False
 
167
 
 
168
    def is_license_exception(self, fname):
 
169
        """Certain files are allowed to be different"""
 
170
        if not self.is_our_code(fname):
 
171
            return True
 
172
        for exc in LICENSE_EXCEPTIONS:
 
173
            if fname.endswith(exc):
 
174
                return True
 
175
        return False
 
176
 
 
177
    def test_tmpdir_not_in_source_files(self):
 
178
        """When scanning for source files, we don't descend test tempdirs"""
 
179
        for filename in self.get_source_files():
 
180
            if re.search(r'test....\.tmp', filename):
 
181
                self.fail("get_source_file() returned filename %r "
 
182
                          "from within a temporary directory"
 
183
                          % filename)
 
184
 
 
185
    def test_copyright(self):
 
186
        """Test that all .py and .pyx files have a valid copyright statement"""
 
187
        incorrect = []
 
188
 
 
189
        copyright_re = re.compile('#\\s*copyright.*(?=\n)', re.I)
 
190
        copyright_canonical_re = re.compile(
 
191
            r'# Copyright \(C\) ' # Opening "# Copyright (C)"
 
192
            r'(\d+)(, \d+)*' # Followed by a series of dates
 
193
            r'.*Canonical Ltd' # And containing 'Canonical Ltd'
 
194
            )
 
195
 
 
196
        for fname, text in self.get_source_file_contents(
 
197
                extensions=('.py', '.pyx')):
 
198
            if self.is_copyright_exception(fname):
 
199
                continue
 
200
            match = copyright_canonical_re.search(text)
 
201
            if not match:
 
202
                match = copyright_re.search(text)
 
203
                if match:
 
204
                    incorrect.append((fname, 'found: %s' % (match.group(),)))
 
205
                else:
 
206
                    incorrect.append((fname, 'no copyright line found\n'))
 
207
            else:
 
208
                if 'by Canonical' in match.group():
 
209
                    incorrect.append((fname,
 
210
                        'should not have: "by Canonical": %s'
 
211
                        % (match.group(),)))
 
212
 
 
213
        if incorrect:
 
214
            help_text = ["Some files have missing or incorrect copyright"
 
215
                         " statements.",
 
216
                         "",
 
217
                         "Please either add them to the list of"
 
218
                         " COPYRIGHT_EXCEPTIONS in"
 
219
                         " bzrlib/tests/test_source.py",
 
220
                         # this is broken to prevent a false match
 
221
                         "or add '# Copyright (C)"
 
222
                         " 2007 Canonical Ltd' to these files:",
 
223
                         "",
 
224
                        ]
 
225
            for fname, comment in incorrect:
 
226
                help_text.append(fname)
 
227
                help_text.append((' '*4) + comment)
 
228
 
 
229
            self.fail('\n'.join(help_text))
 
230
 
 
231
    def test_gpl(self):
 
232
        """Test that all .py and .pyx files have a GPL disclaimer."""
 
233
        incorrect = []
 
234
 
 
235
        gpl_txt = """
 
236
# This program is free software; you can redistribute it and/or modify
 
237
# it under the terms of the GNU General Public License as published by
 
238
# the Free Software Foundation; either version 2 of the License, or
 
239
# (at your option) any later version.
 
240
#
 
241
# This program is distributed in the hope that it will be useful,
 
242
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
243
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
244
# GNU General Public License for more details.
 
245
#
 
246
# You should have received a copy of the GNU General Public License
 
247
# along with this program; if not, write to the Free Software
 
248
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
 
249
"""
 
250
        gpl_re = re.compile(re.escape(gpl_txt), re.MULTILINE)
 
251
 
 
252
        for fname, text in self.get_source_file_contents(
 
253
                extensions=('.py', '.pyx')):
 
254
            if self.is_license_exception(fname):
 
255
                continue
 
256
            if not gpl_re.search(text):
 
257
                incorrect.append(fname)
 
258
 
 
259
        if incorrect:
 
260
            help_text = ['Some files have missing or incomplete GPL statement',
 
261
                         "",
 
262
                         "Please either add them to the list of"
 
263
                         " LICENSE_EXCEPTIONS in"
 
264
                         " bzrlib/tests/test_source.py",
 
265
                         "Or add the following text to the beginning:",
 
266
                         gpl_txt
 
267
                        ]
 
268
            for fname in incorrect:
 
269
                help_text.append((' '*4) + fname)
 
270
 
 
271
            self.fail('\n'.join(help_text))
 
272
 
 
273
    def _push_file(self, dict_, fname, line_no):
 
274
        if fname not in dict_:
 
275
            dict_[fname] = [line_no]
 
276
        else:
 
277
            dict_[fname].append(line_no)
 
278
 
 
279
    def _format_message(self, dict_, message):
 
280
        files = ["%s: %s" % (f, ', '.join([str(i+1) for i in lines]))
 
281
                for f, lines in dict_.items()]
 
282
        files.sort()
 
283
        return message + '\n\n    %s' % ('\n    '.join(files))
 
284
 
 
285
    def test_coding_style(self):
 
286
        """Check if bazaar code conforms to some coding style conventions.
 
287
 
 
288
        Currently we assert that the following is not present:
 
289
         * any tab characters
 
290
         * non-unix newlines
 
291
         * no newline at end of files
 
292
 
 
293
        Print how many files have
 
294
         * trailing white space
 
295
         * lines longer than 79 chars
 
296
        """
 
297
        tabs = {}
 
298
        trailing_ws = {}
 
299
        illegal_newlines = {}
 
300
        long_lines = {}
 
301
        no_newline_at_eof = []
 
302
        for fname, text in self.get_source_file_contents(
 
303
                extensions=('.py', '.pyx')):
 
304
            if not self.is_our_code(fname):
 
305
                continue
 
306
            lines = text.splitlines(True)
 
307
            last_line_no = len(lines) - 1
 
308
            for line_no, line in enumerate(lines):
 
309
                if '\t' in line:
 
310
                    self._push_file(tabs, fname, line_no)
 
311
                if not line.endswith('\n') or line.endswith('\r\n'):
 
312
                    if line_no != last_line_no: # not no_newline_at_eof
 
313
                        self._push_file(illegal_newlines, fname, line_no)
 
314
                if line.endswith(' \n'):
 
315
                    self._push_file(trailing_ws, fname, line_no)
 
316
                if len(line) > 80:
 
317
                    self._push_file(long_lines, fname, line_no)
 
318
            if not lines[-1].endswith('\n'):
 
319
                no_newline_at_eof.append(fname)
 
320
        problems = []
 
321
        if tabs:
 
322
            problems.append(self._format_message(tabs,
 
323
                'Tab characters were found in the following source files.'
 
324
                '\nThey should either be replaced by "\\t" or by spaces:'))
 
325
        if trailing_ws:
 
326
            print ("There are %i lines with trailing white space in %i files."
 
327
                % (sum([len(lines) for f, lines in trailing_ws.items()]),
 
328
                    len(trailing_ws)))
 
329
        if illegal_newlines:
 
330
            problems.append(self._format_message(illegal_newlines,
 
331
                'Non-unix newlines were found in the following source files:'))
 
332
        if long_lines:
 
333
            print ("There are %i lines longer than 79 characters in %i files."
 
334
                % (sum([len(lines) for f, lines in long_lines.items()]),
 
335
                    len(long_lines)))
 
336
        if no_newline_at_eof:
 
337
            no_newline_at_eof.sort()
 
338
            problems.append("The following source files doesn't have a "
 
339
                "newline at the end:"
 
340
               '\n\n    %s'
 
341
               % ('\n    '.join(no_newline_at_eof)))
 
342
        if problems:
 
343
            self.fail('\n\n'.join(problems))
 
344
 
 
345
    def test_no_asserts(self):
 
346
        """bzr shouldn't use the 'assert' statement."""
 
347
        # assert causes too much variation between -O and not, and tends to
 
348
        # give bad errors to the user
 
349
        def search(x):
 
350
            # scan down through x for assert statements, report any problems
 
351
            # this is a bit cheesy; it may get some false positives?
 
352
            if x[0] == symbol.assert_stmt:
 
353
                return True
 
354
            elif x[0] == token.NAME:
 
355
                # can't search further down
 
356
                return False
 
357
            for sub in x[1:]:
 
358
                if sub and search(sub):
 
359
                    return True
 
360
            return False
 
361
        badfiles = []
 
362
        assert_re = re.compile(r'\bassert\b')
 
363
        for fname, text in self.get_source_file_contents():
 
364
            if not self.is_our_code(fname):
 
365
                continue
 
366
            if not assert_re.search(text):
 
367
                continue
 
368
            ast = parser.ast2tuple(parser.suite(text))
 
369
            if search(ast):
 
370
                badfiles.append(fname)
 
371
        if badfiles:
 
372
            self.fail(
 
373
                "these files contain an assert statement and should not:\n%s"
 
374
                % '\n'.join(badfiles))
 
375
 
 
376
    def test_extension_exceptions(self):
 
377
        """Extension functions should propagate exceptions.
 
378
 
 
379
        Either they should return an object, have an 'except' clause, or have a
 
380
        "# cannot_raise" to indicate that we've audited them and defined them as not
 
381
        raising exceptions.
 
382
        """
 
383
        both_exc_and_no_exc = []
 
384
        missing_except = []
 
385
        class_re = re.compile(r'^(cdef\s+)?(public\s+)?(api\s+)?class (\w+).*:',
 
386
                              re.MULTILINE)
 
387
        except_re = re.compile(r'cdef\s+' # start with cdef
 
388
                               r'([\w *]*?)\s*' # this is the return signature
 
389
                               r'(\w+)\s*\(' # the function name
 
390
                               r'[^)]*\)\s*' # parameters
 
391
                               r'(.*)\s*:' # the except clause
 
392
                               r'\s*(#\s*cannot[- _]raise)?' # cannot raise comment
 
393
                              )
 
394
        for fname, text in self.get_source_file_contents(
 
395
                extensions=('.pyx',)):
 
396
            known_classes = set([m[-1] for m in class_re.findall(text)])
 
397
            cdefs = except_re.findall(text)
 
398
            for sig, func, exc_clause, no_exc_comment in cdefs:
 
399
                if sig.startswith('api '):
 
400
                    sig = sig[4:]
 
401
                if not sig or sig in known_classes:
 
402
                    sig = 'object'
 
403
                if 'nogil' in exc_clause:
 
404
                    exc_clause = exc_clause.replace('nogil', '').strip()
 
405
                if exc_clause and no_exc_comment:
 
406
                    both_exc_and_no_exc.append((fname, func))
 
407
                if sig != 'object' and not (exc_clause or no_exc_comment):
 
408
                    missing_except.append((fname, func))
 
409
        error_msg = []
 
410
        if both_exc_and_no_exc:
 
411
            error_msg.append('The following functions had "cannot raise" comments'
 
412
                             ' but did have an except clause set:')
 
413
            for fname, func in both_exc_and_no_exc:
 
414
                error_msg.append('%s:%s' % (fname, func))
 
415
            error_msg.extend(('', ''))
 
416
        if missing_except:
 
417
            error_msg.append('The following functions have fixed return types,'
 
418
                             ' but no except clause.')
 
419
            error_msg.append('Either add an except or append "# cannot_raise".')
 
420
            for fname, func in missing_except:
 
421
                error_msg.append('%s:%s' % (fname, func))
 
422
            error_msg.extend(('', ''))
 
423
        if error_msg:
 
424
            self.fail('\n'.join(error_msg))