~launchpad-pqm/launchpad/devel

10637.3.1 by Guilherme Salgado
Use the default python version instead of a hard-coded version
1
#!/usr/bin/python
9265.1.1 by Jonathan Lange
Add lp-dev-tools that lack license confusion, changing the license to match
2
#
3
# Copyright 2009 Canonical Ltd.  This software is licensed under the
4
# GNU Affero General Public License version 3 (see the file LICENSE).
5
6
"""top-tests.py - Report about slowest tests in the test suite.
7
8
It parses the output of the testrunner run with -vvv and collects
9
statistics about the test run.
10
"""
11
12
__metaclass__ = type
13
14
import operator
15
import os
14612.2.6 by William Grant
utilities
16
import re
9265.1.1 by Jonathan Lange
Add lp-dev-tools that lack license confusion, changing the license to match
17
import sys
18
14612.2.6 by William Grant
utilities
19
9265.1.1 by Jonathan Lange
Add lp-dev-tools that lack license confusion, changing the license to match
20
LEN = 20
21
22
class ParseException(Exception):
23
    """Exception raised when there is an error while parsing a log file."""
24
25
26
class TestRunnerStats:
27
    """Encapsulates information about the time it took to run a testsuite."""
28
29
    LAYER_STARTS_RE = re.compile(r'Running (.+) tests:')
30
31
    LAYER_ENDS_RE = re.compile(
32
        r'  Ran (\d+) tests with (\d+) failures and (\d+) errors in ([\d.]+) '
33
        'seconds.')
34
35
    SETUP_RE = re.compile(r'  Set up ([\w.]+) in ([\d.]+) seconds.')
36
37
    TEARDOWN_RE = re.compile(r'  Tear down ([\w.]+) in ([\d.]+) seconds.')
38
39
    UNSUPPORTED_TEARDOWN_RE = re.compile(
40
        r'  Tear down ([\w.]+) ... not supported')
41
42
    # We are not restricting this to the standard python identifiers because
43
    # some doctest unittest or generated tests could contain funky names.
44
    PYTHON_TEST_RE = re.compile(r'([^\( ]+) ?\(([^\)]+)\)')
45
46
    MS_RE = re.compile(r'\s*\(([\d.]+) ms\)$')
47
48
    TOTAL_RE = re.compile(r'Total: (\d+) tests, (\d+) failures, (\d+) errors')
49
50
    # List of strings/patterns to attempt at matching.
51
    # The second element in the tuple is the method to call when the start of
52
    # the current line matches the string or the pattern.
53
    MATCH_LIST = [
54
        ('Running tests at level', 'handleStartTestRunner'),
55
        (LAYER_STARTS_RE, 'handleLayerStart'),
56
        (LAYER_ENDS_RE, 'handleLayerEnd'),
57
        (SETUP_RE, 'handleLayerSetUp'),
58
        (TEARDOWN_RE, 'handleLayerTearDown'),
59
        (UNSUPPORTED_TEARDOWN_RE, 'handleUnsupportedTearDown'),
60
        ('  Running:', None),
61
        ('Tearing down left over layers:', 'handleFinalTearDown'),
62
        (MS_RE, 'handleTestRuntime'),
63
        (LAYER_ENDS_RE, 'handleLayerEnd'),
64
        (TEARDOWN_RE, 'handleLayerTearDown'),
65
        (TOTAL_RE, 'handleTotal'),
66
        ('    ', 'handleTestRun'),
67
        (None, 'handleGarbage'),
68
        ]
69
70
    def __init__(self, logfile):
71
        """Create a new TestRunnerStats from a log file.
72
73
        :param logfile: Open file-like object containing the log of the test
74
            suite. That should have been generated at -vvv for maximum
75
            information.
76
        :raise ParseException: when the log file doesn't contain a testrunner
77
            log, or couldn't be parsed for some other reasons.
78
        """
79
        self.logfile = logfile
80
        self._parse()
81
82
    def _parse(self):
83
        """Extract timing information from the log file."""
84
        self.layers = {}
85
        self.ignored_lines = []
86
        self.current_layer = None
87
        self.last_test = None
88
89
        end_of_tests = False
90
        while not end_of_tests:
91
            line = self.logfile.readline()
92
            if not line:
93
                break
94
            for match, action in self.MATCH_LIST:
95
                found = False
96
                if isinstance(match, basestring):
97
                    if line.startswith(match):
98
                        found = True
99
                elif match is None:
100
                    # None indicates the default action.
101
                    found = True
102
                elif getattr(match, 'match', None):
103
                    found = match.match(line)
104
                if found:
105
                    # Action is the name of the method to call.
106
                    # If it returns False, stop parsing.
107
                    if action is not None:
108
                        end_of_tests = getattr(self, action)(line, found)
109
                    break
110
111
        if not end_of_tests:
112
            raise ParseException('End of file before end of test run.')
113
114
    def handleStartTestRunner(self, line, ignored):
115
        """Switch the the layer state."""
116
117
    def handleLayerStart(self, line, match):
118
        """Create a new stats container for the layer."""
119
        layer_name = match.group(1)
120
        self.current_layer = self.getLayer(layer_name)
121
122
    def handleLayerEnd(self, line, match):
123
        """Collect the total runtime for the layer tests."""
124
        tests_run = match.group(1)
125
        runtime = match.group(4)
126
        self.current_layer.collectEndResults(tests_run, runtime)
127
128
    def handleLayerSetUp(self, line, match):
129
        """Collect the runtime for the layer set up."""
130
        layer_name = match.group(1)
131
        runtime = float(match.group(2))
132
        self.getLayer(layer_name).collectSetUp(runtime)
133
134
    def handleLayerTearDown(self, line, match):
135
        """Collect the runtime for the layer tear down."""
136
        layer_name = match.group(1)
137
        runtime = float(match.group(2))
138
        self.getLayer(layer_name).collectTearDown(runtime)
139
140
    def handleUnsupportedTearDown(self, line, match):
141
        """Flag that tear down was unsupported."""
142
        layer_name = match.group(1)
143
        self.getLayer(layer_name).collectUnsupportedTearDown()
144
145
    def handleFinalTearDown(self, line, match):
146
        """Switch to teardown state."""
147
148
    def handleTestRun(self, line, ignored):
149
        """Collect that a test was run."""
150
        # If we didn't saw the last test runtime, we are probably
151
        # in a stack trace or something like that. So treat it as garbage.
152
        if self.last_test is not None and not self.last_test_complete:
153
            if self.MS_RE.search(line) is None:
154
                self.handleGarbage(line, ignored)
155
                return
156
            else:
157
                # It happens that a test doesn't output timing information.
158
                # But other tests after that will. 
159
                # We are probably encountering such a case.
160
                pass
161
        line = line[4:]
162
        if '/' in line:
163
            if ' ' in line:
164
                doctest, line = line.split(' ', 1)
165
            else:
166
                doctest = line
167
                line = '\n'
168
            self.last_test = DocTestStats(doctest)
169
        else:
170
            match = self.PYTHON_TEST_RE.match(line)
171
            if match:
172
                self.last_test = PythonTestStats(
173
                    match.group(1), match.group(2))
174
            else:
175
                raise ParseException("can't parse test name: %s" % line)
176
            line = line[match.end():]
177
        self.current_layer.collectTest(self.last_test)
178
179
        # If the runtime isn't on this line, it means that there was output
180
        # by the test, so we'll find the runtime info later on.
181
        match = self.MS_RE.search(line)
182
        if match:
183
            self.last_test_complete = True
184
            self.last_test.runtime = float(match.group(1))
185
        else:
186
            self.last_test_complete = False
187
            self.last_test.collectGarbage(line)
188
189
    def handleGarbage(self, line, ignored):
190
        """Save the log output by the test."""
191
        if self.last_test is not None:
192
            self.last_test.collectGarbage(line)
193
        else:
194
            self.ignored_lines.append(line)
195
196
    def handleTestRuntime(self, line, match):
197
        """Collect the broken test runtime."""
198
        if self.last_test is not None:
199
            self.last_test.runtime = float(match.group(1))
200
            self.last_test_complete = True
201
        else:
202
            self.ignored_lines.append(line)
203
204
    def handleTotal(self, line, match):
205
        """Action invoked when the final line is encountered."""
206
        self.current_layer = None
207
        self.last_test = None
208
        return True
209
210
    def getLayer(self, layer_name):
211
        """Return the layer with name.
212
213
        Create and return an empty layer if it doesn't exists.
214
        """
215
        if layer_name not in self.layers:
216
            self.layers[layer_name] = TestLayerStats(layer_name)
217
        return self.layers[layer_name]
218
219
    def getTestsIter(self):
220
        """Return an iterator over all tests."""
221
        for layer in self.layers.values():
222
            for test in layer.tests:
223
                yield test
224
225
    @property
226
    def total_runtime(self):
227
        """Number of seconds used to run the whole test suite."""
228
        return sum([layer.total_runtime for layer in self.layers.values()])
229
230
    @property
231
    def tests_count(self):
232
        """Number of tests in the test suite."""
233
        return sum([len(layer.tests) for layer in self.layers.values()])
234
235
236
class TestLayerStats:
237
    """Contain all the tests that were run in the layer."""
238
239
    name = None
240
    unsupported_tear_downs = 0
241
242
    tests_runtime = 0
243
244
    def __init__(self, name):
245
        """Create a new stats container."""
246
        self.name = name
247
        self.tests = []
248
        self.set_ups = []
249
        self.tear_downs = []
250
251
    @property
252
    def total_runtime(self):
253
        """Return the runtime (including fixture) in this layer."""
254
        return self.tests_runtime + sum(self.set_ups) + sum(self.tear_downs)
255
256
    def collectTest(self, test):
257
        """Call when a test was run in the layer."""
258
        self.tests.append(test)
259
260
    def collectEndResults(self, tests_run, runtime):
261
        """Called when all the tests in the layer were run."""
262
        self.tests_runtime = float(runtime)
263
        self.tests_count = int(tests_run)
264
265
    def collectSetUp(self, runtime):
266
        """Called when the layer was set up."""
267
        self.set_ups.append(runtime)
268
269
    def collectTearDown(self, runtime):
270
        """Called when the layer was torn down."""
271
        self.tear_downs.append(runtime)
272
273
    def collectUnsupportedTearDown(self):
274
        """Called when the layer couldn't be torn down."""
275
        self.unsupported_tear_downs += 1
276
277
    def __iter__(self):
278
        """Return an iterator over the tests run in this layer."""
279
        return iter(self.tests)
280
281
282
class TestStats:
283
    """Base class for a test stats."""
284
285
    name = None
286
    runtime = 0
287
288
    def __init__(self):
289
        self._garbage = []
290
291
    @property
292
    def garbage(self):
293
        """Return the garbage output by the test."""
294
        return "".join(self._garbage)
295
296
    def collectGarbage(self, line):
297
        self._garbage.append(line)
298
299
300
class PythonTestStats(TestStats):
301
    """Stats for a regular python unit test."""
302
303
    def __init__(self, method, module):
304
        super(PythonTestStats, self).__init__()
305
        self.method = method
306
        self.module = module
307
308
    @property
309
    def name(self):
310
        """Return the full name of the test."""
311
        return "%s.%s" % (self.module, self.method)
312
313
314
class DocTestStats(TestStats):
315
    """Stats for a doctest."""
316
317
    def __init__(self, filename):
318
        super(DocTestStats, self).__init__()
319
        self.filename = filename
320
321
    @property
322
    def name(self):
323
        """Remove the PQM directory from the name."""
324
        index = self.filename.find("lib/canonical")
325
        if index != -1:
326
            filename = self.filename[index:]
327
        else:
328
            filename = self.filename
329
        return os.path.normpath(filename)
330
331
332
class PQMLog:
333
    """Encapsulates information about a PQM log."""
334
335
    def __init__(self, logfile):
336
        """Create a new PQMLog instance.
337
338
        :param logfile: Path to the PQM log.
339
        """
340
        self.logfile = logfile
341
        self.fixtures_profile = []
342
343
        self._parse()
344
345
    def _parse(self):
346
        """Parse a PQM log file.
347
348
        Extract the branch name, the time each tests took as well as the
349
        time spent in the layers.
350
        """
351
        self.branch = "Unknown"
352
        profile = self.fixtures_profile
353
354
        logfile = open(self.logfile)
355
        while True:
356
            line = logfile.readline()
357
            if not line:
358
                break
359
            line = line.strip()
360
            if not line:
361
                continue
362
            if line.startswith("Executing star-merge"):
363
                self.branch = line.split(" ")[2]
364
            elif " calls taking " in line:
365
                if "s." not in line:
366
                    continue
367
                values = line.split(" ")
368
                runtime = float(values[-1][:-2])
369
                profile.append((runtime, values[0]))
370
            elif line.startswith("Executing pre-commit hook"):
371
                self.testrunner_stats = TestRunnerStats(logfile)
372
373
374
def main(argv):
375
    """Parse a PQM log file."""
376
    if len(sys.argv) > 1:
377
        logfile = sys.argv[1]
378
    else:
379
        logfile = find_latest_successful_merge()
380
    print_report(PQMLog(logfile))
381
382
383
def find_latest_successful_merge():
384
    """Return the latest PQM log that contain a successful merge.
385
386
    Look into the current directory for the log files.
387
    """
388
    cmd = "ls -at | head -10 | xargs grep -l 'star-merge succeeded'"
389
    p = os.popen(cmd)
390
    logfile_name = p.readlines()[0].strip()
391
    p.close()
392
    return logfile_name
393
394
395
def print_report(pqm_log, out=sys.stdout):
396
    """Print the report on STDOUT."""
397
398
    print >>out, "Log: %s" % pqm_log.logfile
399
    print >>out, "Branch: %s" % pqm_log.branch
400
401
    stats = pqm_log.testrunner_stats
402
403
    top_tests =  list(stats.getTestsIter())
404
    top_tests.sort(key=operator.attrgetter('runtime'), reverse=True)
405
406
    total_runtime = stats.total_runtime
407
    tests_count = stats.tests_count
408
409
    print >>out
410
    print >>out, "Top %d tests taking the longest time" % LEN
411
    print >>out, "===================================="
412
    print
413
    top_runtime = 0.0
414
    for test in top_tests[:LEN]:
415
        percent = test.runtime / total_runtime * 100
416
        top_runtime += test.runtime
417
        print >>out, "%6.2f (%.1f%%) %s" % (test.runtime, percent, test.name)
418
    print >>out
419
    test_percent = LEN / float(tests_count) * 100
420
    time_percent = top_runtime / total_runtime * 100
421
    print >>out, (
422
        "Top %s of %s (%.1f%%) tests taking %ss of %ss (%.1f%%)"
423
        % (LEN, tests_count, test_percent, top_runtime, total_runtime,
424
           time_percent))
425
    print >>out
426
427
    print >>out, "Tests and runtime by layer"
428
    print >>out, "=========================="
429
    print >>out
430
431
    layers = stats.layers.values()
432
    layers.sort(key=operator.attrgetter('total_runtime'), reverse=True)
433
    for layer in layers:
434
        if len(layer.tests) == 0:
435
            continue
436
        runtime_percent = layer.tests_runtime / total_runtime * 100
437
        layer_name = layer.name.split('.')[-1]
438
        print "%7.2f (%4.1f%%) %4d tests (%5.2fs/t) %s" % (
439
            layer.tests_runtime, runtime_percent, len(layer.tests),
440
            layer.tests_runtime / len(layer.tests), layer_name)
441
442
443
    print >>out
444
    print >>out, "Slowest fixture methods"
445
    print >>out, "======================="
446
    print >>out
447
448
    profile = list(pqm_log.fixtures_profile)
449
    profile.sort(reverse=True)
450
    print >>out
451
    fixture_runtime = 0
452
    for runtime, method in profile:
453
        runtime_percent = runtime / total_runtime * 100
454
        print >>out, "%7.2f (%4.1f%%) %s" % (runtime, runtime_percent, method)
455
        fixture_runtime += runtime
456
457
    print >>out
458
    print >>out, "Fixture overhead %ss (%.1f%%)" % (
459
        fixture_runtime, fixture_runtime / total_runtime * 100)
460
    print >>out
461
462
    tests_with_garbage = 0
463
    garbage_lines_count = 0
464
    for test in stats.getTestsIter():
465
        if len(test.garbage):
466
            tests_with_garbage += 1
467
            garbage_lines_count += test.garbage.strip().count('\n')+1
468
469
    print >>out, "%d tests output %d warning lines." % (
470
        tests_with_garbage, garbage_lines_count)
471
    print >>out, "Ignored %d lines in the testrunner output." % len(
472
        stats.ignored_lines)
473
    print >>out
474
475
476
if __name__ == '__main__':
477
    main(sys.argv)