3
# Copyright 2009 Canonical Ltd. This software is licensed under the
4
# GNU Affero General Public License version 3 (see the file LICENSE).
6
"""top-tests.py - Report about slowest tests in the test suite.
8
It parses the output of the testrunner run with -vvv and collects
9
statistics about the test run.
21
class ParseException(Exception):
22
"""Exception raised when there is an error while parsing a log file."""
25
class TestRunnerStats:
26
"""Encapsulates information about the time it took to run a testsuite."""
28
LAYER_STARTS_RE = re.compile(r'Running (.+) tests:')
30
LAYER_ENDS_RE = re.compile(
31
r' Ran (\d+) tests with (\d+) failures and (\d+) errors in ([\d.]+) '
34
SETUP_RE = re.compile(r' Set up ([\w.]+) in ([\d.]+) seconds.')
36
TEARDOWN_RE = re.compile(r' Tear down ([\w.]+) in ([\d.]+) seconds.')
38
UNSUPPORTED_TEARDOWN_RE = re.compile(
39
r' Tear down ([\w.]+) ... not supported')
41
# We are not restricting this to the standard python identifiers because
42
# some doctest unittest or generated tests could contain funky names.
43
PYTHON_TEST_RE = re.compile(r'([^\( ]+) ?\(([^\)]+)\)')
45
MS_RE = re.compile(r'\s*\(([\d.]+) ms\)$')
47
TOTAL_RE = re.compile(r'Total: (\d+) tests, (\d+) failures, (\d+) errors')
49
# List of strings/patterns to attempt at matching.
50
# The second element in the tuple is the method to call when the start of
51
# the current line matches the string or the pattern.
53
('Running tests at level', 'handleStartTestRunner'),
54
(LAYER_STARTS_RE, 'handleLayerStart'),
55
(LAYER_ENDS_RE, 'handleLayerEnd'),
56
(SETUP_RE, 'handleLayerSetUp'),
57
(TEARDOWN_RE, 'handleLayerTearDown'),
58
(UNSUPPORTED_TEARDOWN_RE, 'handleUnsupportedTearDown'),
60
('Tearing down left over layers:', 'handleFinalTearDown'),
61
(MS_RE, 'handleTestRuntime'),
62
(LAYER_ENDS_RE, 'handleLayerEnd'),
63
(TEARDOWN_RE, 'handleLayerTearDown'),
64
(TOTAL_RE, 'handleTotal'),
65
(' ', 'handleTestRun'),
66
(None, 'handleGarbage'),
69
def __init__(self, logfile):
70
"""Create a new TestRunnerStats from a log file.
72
:param logfile: Open file-like object containing the log of the test
73
suite. That should have been generated at -vvv for maximum
75
:raise ParseException: when the log file doesn't contain a testrunner
76
log, or couldn't be parsed for some other reasons.
78
self.logfile = logfile
82
"""Extract timing information from the log file."""
84
self.ignored_lines = []
85
self.current_layer = None
89
while not end_of_tests:
90
line = self.logfile.readline()
93
for match, action in self.MATCH_LIST:
95
if isinstance(match, basestring):
96
if line.startswith(match):
99
# None indicates the default action.
101
elif getattr(match, 'match', None):
102
found = match.match(line)
104
# Action is the name of the method to call.
105
# If it returns False, stop parsing.
106
if action is not None:
107
end_of_tests = getattr(self, action)(line, found)
111
raise ParseException('End of file before end of test run.')
113
def handleStartTestRunner(self, line, ignored):
114
"""Switch the the layer state."""
116
def handleLayerStart(self, line, match):
117
"""Create a new stats container for the layer."""
118
layer_name = match.group(1)
119
self.current_layer = self.getLayer(layer_name)
121
def handleLayerEnd(self, line, match):
122
"""Collect the total runtime for the layer tests."""
123
tests_run = match.group(1)
124
runtime = match.group(4)
125
self.current_layer.collectEndResults(tests_run, runtime)
127
def handleLayerSetUp(self, line, match):
128
"""Collect the runtime for the layer set up."""
129
layer_name = match.group(1)
130
runtime = float(match.group(2))
131
self.getLayer(layer_name).collectSetUp(runtime)
133
def handleLayerTearDown(self, line, match):
134
"""Collect the runtime for the layer tear down."""
135
layer_name = match.group(1)
136
runtime = float(match.group(2))
137
self.getLayer(layer_name).collectTearDown(runtime)
139
def handleUnsupportedTearDown(self, line, match):
140
"""Flag that tear down was unsupported."""
141
layer_name = match.group(1)
142
self.getLayer(layer_name).collectUnsupportedTearDown()
144
def handleFinalTearDown(self, line, match):
145
"""Switch to teardown state."""
147
def handleTestRun(self, line, ignored):
148
"""Collect that a test was run."""
149
# If we didn't saw the last test runtime, we are probably
150
# in a stack trace or something like that. So treat it as garbage.
151
if self.last_test is not None and not self.last_test_complete:
152
if self.MS_RE.search(line) is None:
153
self.handleGarbage(line, ignored)
156
# It happens that a test doesn't output timing information.
157
# But other tests after that will.
158
# We are probably encountering such a case.
163
doctest, line = line.split(' ', 1)
167
self.last_test = DocTestStats(doctest)
169
match = self.PYTHON_TEST_RE.match(line)
171
self.last_test = PythonTestStats(
172
match.group(1), match.group(2))
174
raise ParseException("can't parse test name: %s" % line)
175
line = line[match.end():]
176
self.current_layer.collectTest(self.last_test)
178
# If the runtime isn't on this line, it means that there was output
179
# by the test, so we'll find the runtime info later on.
180
match = self.MS_RE.search(line)
182
self.last_test_complete = True
183
self.last_test.runtime = float(match.group(1))
185
self.last_test_complete = False
186
self.last_test.collectGarbage(line)
188
def handleGarbage(self, line, ignored):
189
"""Save the log output by the test."""
190
if self.last_test is not None:
191
self.last_test.collectGarbage(line)
193
self.ignored_lines.append(line)
195
def handleTestRuntime(self, line, match):
196
"""Collect the broken test runtime."""
197
if self.last_test is not None:
198
self.last_test.runtime = float(match.group(1))
199
self.last_test_complete = True
201
self.ignored_lines.append(line)
203
def handleTotal(self, line, match):
204
"""Action invoked when the final line is encountered."""
205
self.current_layer = None
206
self.last_test = None
209
def getLayer(self, layer_name):
210
"""Return the layer with name.
212
Create and return an empty layer if it doesn't exists.
214
if layer_name not in self.layers:
215
self.layers[layer_name] = TestLayerStats(layer_name)
216
return self.layers[layer_name]
218
def getTestsIter(self):
219
"""Return an iterator over all tests."""
220
for layer in self.layers.values():
221
for test in layer.tests:
225
def total_runtime(self):
226
"""Number of seconds used to run the whole test suite."""
227
return sum([layer.total_runtime for layer in self.layers.values()])
230
def tests_count(self):
231
"""Number of tests in the test suite."""
232
return sum([len(layer.tests) for layer in self.layers.values()])
235
class TestLayerStats:
236
"""Contain all the tests that were run in the layer."""
239
unsupported_tear_downs = 0
243
def __init__(self, name):
244
"""Create a new stats container."""
251
def total_runtime(self):
252
"""Return the runtime (including fixture) in this layer."""
253
return self.tests_runtime + sum(self.set_ups) + sum(self.tear_downs)
255
def collectTest(self, test):
256
"""Call when a test was run in the layer."""
257
self.tests.append(test)
259
def collectEndResults(self, tests_run, runtime):
260
"""Called when all the tests in the layer were run."""
261
self.tests_runtime = float(runtime)
262
self.tests_count = int(tests_run)
264
def collectSetUp(self, runtime):
265
"""Called when the layer was set up."""
266
self.set_ups.append(runtime)
268
def collectTearDown(self, runtime):
269
"""Called when the layer was torn down."""
270
self.tear_downs.append(runtime)
272
def collectUnsupportedTearDown(self):
273
"""Called when the layer couldn't be torn down."""
274
self.unsupported_tear_downs += 1
277
"""Return an iterator over the tests run in this layer."""
278
return iter(self.tests)
282
"""Base class for a test stats."""
292
"""Return the garbage output by the test."""
293
return "".join(self._garbage)
295
def collectGarbage(self, line):
296
self._garbage.append(line)
299
class PythonTestStats(TestStats):
300
"""Stats for a regular python unit test."""
302
def __init__(self, method, module):
303
super(PythonTestStats, self).__init__()
309
"""Return the full name of the test."""
310
return "%s.%s" % (self.module, self.method)
313
class DocTestStats(TestStats):
314
"""Stats for a doctest."""
316
def __init__(self, filename):
317
super(DocTestStats, self).__init__()
318
self.filename = filename
322
"""Remove the PQM directory from the name."""
323
index = self.filename.find("lib/canonical")
325
filename = self.filename[index:]
327
filename = self.filename
328
return os.path.normpath(filename)
332
"""Encapsulates information about a PQM log."""
334
def __init__(self, logfile):
335
"""Create a new PQMLog instance.
337
:param logfile: Path to the PQM log.
339
self.logfile = logfile
340
self.fixtures_profile = []
345
"""Parse a PQM log file.
347
Extract the branch name, the time each tests took as well as the
348
time spent in the layers.
350
self.branch = "Unknown"
351
profile = self.fixtures_profile
353
logfile = open(self.logfile)
355
line = logfile.readline()
361
if line.startswith("Executing star-merge"):
362
self.branch = line.split(" ")[2]
363
elif " calls taking " in line:
366
values = line.split(" ")
367
runtime = float(values[-1][:-2])
368
profile.append((runtime, values[0]))
369
elif line.startswith("Executing pre-commit hook"):
370
self.testrunner_stats = TestRunnerStats(logfile)
374
"""Parse a PQM log file."""
375
if len(sys.argv) > 1:
376
logfile = sys.argv[1]
378
logfile = find_latest_successful_merge()
379
print_report(PQMLog(logfile))
382
def find_latest_successful_merge():
383
"""Return the latest PQM log that contain a successful merge.
385
Look into the current directory for the log files.
387
cmd = "ls -at | head -10 | xargs grep -l 'star-merge succeeded'"
389
logfile_name = p.readlines()[0].strip()
394
def print_report(pqm_log, out=sys.stdout):
395
"""Print the report on STDOUT."""
397
print >>out, "Log: %s" % pqm_log.logfile
398
print >>out, "Branch: %s" % pqm_log.branch
400
stats = pqm_log.testrunner_stats
402
top_tests = list(stats.getTestsIter())
403
top_tests.sort(key=operator.attrgetter('runtime'), reverse=True)
405
total_runtime = stats.total_runtime
406
tests_count = stats.tests_count
409
print >>out, "Top %d tests taking the longest time" % LEN
410
print >>out, "===================================="
413
for test in top_tests[:LEN]:
414
percent = test.runtime / total_runtime * 100
415
top_runtime += test.runtime
416
print >>out, "%6.2f (%.1f%%) %s" % (test.runtime, percent, test.name)
418
test_percent = LEN / float(tests_count) * 100
419
time_percent = top_runtime / total_runtime * 100
421
"Top %s of %s (%.1f%%) tests taking %ss of %ss (%.1f%%)"
422
% (LEN, tests_count, test_percent, top_runtime, total_runtime,
426
print >>out, "Tests and runtime by layer"
427
print >>out, "=========================="
430
layers = stats.layers.values()
431
layers.sort(key=operator.attrgetter('total_runtime'), reverse=True)
433
if len(layer.tests) == 0:
435
runtime_percent = layer.tests_runtime / total_runtime * 100
436
layer_name = layer.name.split('.')[-1]
437
print "%7.2f (%4.1f%%) %4d tests (%5.2fs/t) %s" % (
438
layer.tests_runtime, runtime_percent, len(layer.tests),
439
layer.tests_runtime / len(layer.tests), layer_name)
443
print >>out, "Slowest fixture methods"
444
print >>out, "======================="
447
profile = list(pqm_log.fixtures_profile)
448
profile.sort(reverse=True)
451
for runtime, method in profile:
452
runtime_percent = runtime / total_runtime * 100
453
print >>out, "%7.2f (%4.1f%%) %s" % (runtime, runtime_percent, method)
454
fixture_runtime += runtime
457
print >>out, "Fixture overhead %ss (%.1f%%)" % (
458
fixture_runtime, fixture_runtime / total_runtime * 100)
461
tests_with_garbage = 0
462
garbage_lines_count = 0
463
for test in stats.getTestsIter():
464
if len(test.garbage):
465
tests_with_garbage += 1
466
garbage_lines_count += test.garbage.strip().count('\n')+1
468
print >>out, "%d tests output %d warning lines." % (
469
tests_with_garbage, garbage_lines_count)
470
print >>out, "Ignored %d lines in the testrunner output." % len(
475
if __name__ == '__main__':