Sean Dague | 50af5d5 | 2014-05-02 14:48:44 -0400 | [diff] [blame] | 1 | #!/usr/bin/env python |
| 2 | |
| 3 | # Copyright 2014 Hewlett-Packard Development Company, L.P. |
| 4 | # Copyright 2014 Samsung Electronics |
| 5 | # All Rights Reserved. |
| 6 | # |
| 7 | # Licensed under the Apache License, Version 2.0 (the "License"); you may |
| 8 | # not use this file except in compliance with the License. You may obtain |
| 9 | # a copy of the License at |
| 10 | # |
| 11 | # http://www.apache.org/licenses/LICENSE-2.0 |
| 12 | # |
| 13 | # Unless required by applicable law or agreed to in writing, software |
| 14 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| 15 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| 16 | # License for the specific language governing permissions and limitations |
| 17 | # under the License. |
| 18 | |
| 19 | """Trace a subunit stream in reasonable detail and high accuracy.""" |
| 20 | |
Matthew Treinish | 1d5f32b | 2014-06-04 15:37:58 -0400 | [diff] [blame] | 21 | import argparse |
Sean Dague | 50af5d5 | 2014-05-02 14:48:44 -0400 | [diff] [blame] | 22 | import functools |
Sean Dague | 0486744 | 2014-05-06 08:51:15 -0400 | [diff] [blame] | 23 | import re |
Sean Dague | 50af5d5 | 2014-05-02 14:48:44 -0400 | [diff] [blame] | 24 | import sys |
| 25 | |
| 26 | import mimeparse |
| 27 | import subunit |
| 28 | import testtools |
| 29 | |
| 30 | DAY_SECONDS = 60 * 60 * 24 |
| 31 | FAILS = [] |
| 32 | RESULTS = {} |
| 33 | |
| 34 | |
| 35 | class Starts(testtools.StreamResult): |
| 36 | |
| 37 | def __init__(self, output): |
| 38 | super(Starts, self).__init__() |
| 39 | self._output = output |
| 40 | |
| 41 | def startTestRun(self): |
| 42 | self._neednewline = False |
| 43 | self._emitted = set() |
| 44 | |
| 45 | def status(self, test_id=None, test_status=None, test_tags=None, |
| 46 | runnable=True, file_name=None, file_bytes=None, eof=False, |
| 47 | mime_type=None, route_code=None, timestamp=None): |
| 48 | super(Starts, self).status( |
| 49 | test_id, test_status, |
| 50 | test_tags=test_tags, runnable=runnable, file_name=file_name, |
| 51 | file_bytes=file_bytes, eof=eof, mime_type=mime_type, |
| 52 | route_code=route_code, timestamp=timestamp) |
| 53 | if not test_id: |
| 54 | if not file_bytes: |
| 55 | return |
| 56 | if not mime_type or mime_type == 'test/plain;charset=utf8': |
| 57 | mime_type = 'text/plain; charset=utf-8' |
| 58 | primary, sub, parameters = mimeparse.parse_mime_type(mime_type) |
| 59 | content_type = testtools.content_type.ContentType( |
| 60 | primary, sub, parameters) |
| 61 | content = testtools.content.Content( |
| 62 | content_type, lambda: [file_bytes]) |
| 63 | text = content.as_text() |
| 64 | if text and text[-1] not in '\r\n': |
| 65 | self._neednewline = True |
| 66 | self._output.write(text) |
| 67 | elif test_status == 'inprogress' and test_id not in self._emitted: |
| 68 | if self._neednewline: |
| 69 | self._neednewline = False |
| 70 | self._output.write('\n') |
| 71 | worker = '' |
| 72 | for tag in test_tags or (): |
| 73 | if tag.startswith('worker-'): |
| 74 | worker = '(' + tag[7:] + ') ' |
| 75 | if timestamp: |
| 76 | timestr = timestamp.isoformat() |
| 77 | else: |
| 78 | timestr = '' |
| 79 | self._output.write('%s: %s%s [start]\n' % |
| 80 | (timestr, worker, test_id)) |
| 81 | self._emitted.add(test_id) |
| 82 | |
| 83 | |
| 84 | def cleanup_test_name(name, strip_tags=True, strip_scenarios=False): |
| 85 | """Clean up the test name for display. |
| 86 | |
| 87 | By default we strip out the tags in the test because they don't help us |
| 88 | in identifying the test that is run to it's result. |
| 89 | |
| 90 | Make it possible to strip out the testscenarios information (not to |
| 91 | be confused with tempest scenarios) however that's often needed to |
| 92 | indentify generated negative tests. |
| 93 | """ |
| 94 | if strip_tags: |
| 95 | tags_start = name.find('[') |
| 96 | tags_end = name.find(']') |
| 97 | if tags_start > 0 and tags_end > tags_start: |
| 98 | newname = name[:tags_start] |
| 99 | newname += name[tags_end + 1:] |
| 100 | name = newname |
| 101 | |
| 102 | if strip_scenarios: |
| 103 | tags_start = name.find('(') |
| 104 | tags_end = name.find(')') |
| 105 | if tags_start > 0 and tags_end > tags_start: |
| 106 | newname = name[:tags_start] |
| 107 | newname += name[tags_end + 1:] |
| 108 | name = newname |
| 109 | |
| 110 | return name |
| 111 | |
| 112 | |
| 113 | def get_duration(timestamps): |
| 114 | start, end = timestamps |
| 115 | if not start or not end: |
| 116 | duration = '' |
| 117 | else: |
| 118 | delta = end - start |
| 119 | duration = '%d.%06ds' % ( |
| 120 | delta.days * DAY_SECONDS + delta.seconds, delta.microseconds) |
| 121 | return duration |
| 122 | |
| 123 | |
| 124 | def find_worker(test): |
| 125 | for tag in test['tags']: |
| 126 | if tag.startswith('worker-'): |
| 127 | return int(tag[7:]) |
| 128 | return 'NaN' |
| 129 | |
| 130 | |
| 131 | # Print out stdout/stderr if it exists, always |
| 132 | def print_attachments(stream, test, all_channels=False): |
| 133 | """Print out subunit attachments. |
| 134 | |
| 135 | Print out subunit attachments that contain content. This |
| 136 | runs in 2 modes, one for successes where we print out just stdout |
| 137 | and stderr, and an override that dumps all the attachments. |
| 138 | """ |
| 139 | channels = ('stdout', 'stderr') |
| 140 | for name, detail in test['details'].items(): |
| 141 | # NOTE(sdague): the subunit names are a little crazy, and actually |
| 142 | # are in the form pythonlogging:'' (with the colon and quotes) |
| 143 | name = name.split(':')[0] |
| 144 | if detail.content_type.type == 'test': |
| 145 | detail.content_type.type = 'text' |
| 146 | if (all_channels or name in channels) and detail.as_text(): |
| 147 | title = "Captured %s:" % name |
| 148 | stream.write("\n%s\n%s\n" % (title, ('~' * len(title)))) |
| 149 | # indent attachment lines 4 spaces to make them visually |
| 150 | # offset |
| 151 | for line in detail.as_text().split('\n'): |
| 152 | stream.write(" %s\n" % line) |
| 153 | |
| 154 | |
Matthew Treinish | 1d5f32b | 2014-06-04 15:37:58 -0400 | [diff] [blame] | 155 | def show_outcome(stream, test, print_failures=False): |
Sean Dague | 50af5d5 | 2014-05-02 14:48:44 -0400 | [diff] [blame] | 156 | global RESULTS |
| 157 | status = test['status'] |
| 158 | # TODO(sdague): ask lifeless why on this? |
| 159 | if status == 'exists': |
| 160 | return |
| 161 | |
| 162 | worker = find_worker(test) |
| 163 | name = cleanup_test_name(test['id']) |
| 164 | duration = get_duration(test['timestamps']) |
| 165 | |
| 166 | if worker not in RESULTS: |
| 167 | RESULTS[worker] = [] |
| 168 | RESULTS[worker].append(test) |
| 169 | |
| 170 | # don't count the end of the return code as a fail |
| 171 | if name == 'process-returncode': |
| 172 | return |
| 173 | |
| 174 | if status == 'success': |
| 175 | stream.write('{%s} %s [%s] ... ok\n' % ( |
| 176 | worker, name, duration)) |
| 177 | print_attachments(stream, test) |
| 178 | elif status == 'fail': |
| 179 | FAILS.append(test) |
| 180 | stream.write('{%s} %s [%s] ... FAILED\n' % ( |
| 181 | worker, name, duration)) |
Matthew Treinish | 1d5f32b | 2014-06-04 15:37:58 -0400 | [diff] [blame] | 182 | if not print_failures: |
| 183 | print_attachments(stream, test, all_channels=True) |
Sean Dague | 50af5d5 | 2014-05-02 14:48:44 -0400 | [diff] [blame] | 184 | elif status == 'skip': |
| 185 | stream.write('{%s} %s ... SKIPPED: %s\n' % ( |
| 186 | worker, name, test['details']['reason'].as_text())) |
| 187 | else: |
| 188 | stream.write('{%s} %s [%s] ... %s\n' % ( |
| 189 | worker, name, duration, test['status'])) |
Matthew Treinish | 1d5f32b | 2014-06-04 15:37:58 -0400 | [diff] [blame] | 190 | if not print_failures: |
| 191 | print_attachments(stream, test, all_channels=True) |
Sean Dague | 50af5d5 | 2014-05-02 14:48:44 -0400 | [diff] [blame] | 192 | |
| 193 | stream.flush() |
| 194 | |
| 195 | |
| 196 | def print_fails(stream): |
| 197 | """Print summary failure report. |
| 198 | |
| 199 | Currently unused, however there remains debate on inline vs. at end |
| 200 | reporting, so leave the utility function for later use. |
| 201 | """ |
| 202 | if not FAILS: |
| 203 | return |
| 204 | stream.write("\n==============================\n") |
| 205 | stream.write("Failed %s tests - output below:" % len(FAILS)) |
| 206 | stream.write("\n==============================\n") |
| 207 | for f in FAILS: |
| 208 | stream.write("\n%s\n" % f['id']) |
| 209 | stream.write("%s\n" % ('-' * len(f['id']))) |
| 210 | print_attachments(stream, f, all_channels=True) |
| 211 | stream.write('\n') |
| 212 | |
| 213 | |
Sean Dague | 0486744 | 2014-05-06 08:51:15 -0400 | [diff] [blame] | 214 | def count_tests(key, value): |
| 215 | count = 0 |
| 216 | for k, v in RESULTS.items(): |
| 217 | for item in v: |
| 218 | if key in item: |
| 219 | if re.search(value, item[key]): |
| 220 | count += 1 |
| 221 | return count |
| 222 | |
| 223 | |
Matthew Treinish | 53eef72 | 2014-06-12 17:35:10 -0400 | [diff] [blame] | 224 | def run_time(): |
| 225 | runtime = 0.0 |
| 226 | for k, v in RESULTS.items(): |
| 227 | for test in v: |
| 228 | runtime += float(get_duration(test['timestamps']).strip('s')) |
| 229 | return runtime |
| 230 | |
| 231 | |
Sean Dague | 0486744 | 2014-05-06 08:51:15 -0400 | [diff] [blame] | 232 | def worker_stats(worker): |
| 233 | tests = RESULTS[worker] |
| 234 | num_tests = len(tests) |
| 235 | delta = tests[-1]['timestamps'][1] - tests[0]['timestamps'][0] |
| 236 | return num_tests, delta |
| 237 | |
| 238 | |
| 239 | def print_summary(stream): |
| 240 | stream.write("\n======\nTotals\n======\n") |
Matthew Treinish | 53eef72 | 2014-06-12 17:35:10 -0400 | [diff] [blame] | 241 | stream.write("Run: %s in %s sec.\n" % (count_tests('status', '.*'), |
| 242 | run_time())) |
Sean Dague | 0486744 | 2014-05-06 08:51:15 -0400 | [diff] [blame] | 243 | stream.write(" - Passed: %s\n" % count_tests('status', 'success')) |
| 244 | stream.write(" - Skipped: %s\n" % count_tests('status', 'skip')) |
| 245 | stream.write(" - Failed: %s\n" % count_tests('status', 'fail')) |
| 246 | |
| 247 | # we could have no results, especially as we filter out the process-codes |
| 248 | if RESULTS: |
| 249 | stream.write("\n==============\nWorker Balance\n==============\n") |
| 250 | |
| 251 | for w in range(max(RESULTS.keys()) + 1): |
| 252 | if w not in RESULTS: |
| 253 | stream.write( |
| 254 | " - WARNING: missing Worker %s! " |
| 255 | "Race in testr accounting.\n" % w) |
| 256 | else: |
| 257 | num, time = worker_stats(w) |
| 258 | stream.write(" - Worker %s (%s tests) => %ss\n" % |
| 259 | (w, num, time)) |
| 260 | |
| 261 | |
Matthew Treinish | 1d5f32b | 2014-06-04 15:37:58 -0400 | [diff] [blame] | 262 | def parse_args(): |
| 263 | parser = argparse.ArgumentParser() |
| 264 | parser.add_argument('--no-failure-debug', '-n', action='store_true', |
| 265 | dest='print_failures', help='Disable printing failure ' |
Robert Mizielski | e1d8899 | 2014-07-15 15:28:09 +0200 | [diff] [blame] | 266 | 'debug information in realtime') |
Matthew Treinish | 1d5f32b | 2014-06-04 15:37:58 -0400 | [diff] [blame] | 267 | parser.add_argument('--fails', '-f', action='store_true', |
| 268 | dest='post_fails', help='Print failure debug ' |
| 269 | 'information after the stream is proccesed') |
| 270 | return parser.parse_args() |
| 271 | |
| 272 | |
Sean Dague | 50af5d5 | 2014-05-02 14:48:44 -0400 | [diff] [blame] | 273 | def main(): |
Matthew Treinish | 1d5f32b | 2014-06-04 15:37:58 -0400 | [diff] [blame] | 274 | args = parse_args() |
Sean Dague | 50af5d5 | 2014-05-02 14:48:44 -0400 | [diff] [blame] | 275 | stream = subunit.ByteStreamToStreamResult( |
| 276 | sys.stdin, non_subunit_name='stdout') |
| 277 | starts = Starts(sys.stdout) |
| 278 | outcomes = testtools.StreamToDict( |
Matthew Treinish | 1d5f32b | 2014-06-04 15:37:58 -0400 | [diff] [blame] | 279 | functools.partial(show_outcome, sys.stdout, |
| 280 | print_failures=args.print_failures)) |
Sean Dague | 50af5d5 | 2014-05-02 14:48:44 -0400 | [diff] [blame] | 281 | summary = testtools.StreamSummary() |
| 282 | result = testtools.CopyStreamResult([starts, outcomes, summary]) |
| 283 | result.startTestRun() |
| 284 | try: |
| 285 | stream.run(result) |
| 286 | finally: |
| 287 | result.stopTestRun() |
Matthew Treinish | 1d5f32b | 2014-06-04 15:37:58 -0400 | [diff] [blame] | 288 | if args.post_fails: |
| 289 | print_fails(sys.stdout) |
Sean Dague | 0486744 | 2014-05-06 08:51:15 -0400 | [diff] [blame] | 290 | print_summary(sys.stdout) |
Sean Dague | 50af5d5 | 2014-05-02 14:48:44 -0400 | [diff] [blame] | 291 | return (0 if summary.wasSuccessful() else 1) |
| 292 | |
| 293 | |
| 294 | if __name__ == '__main__': |
| 295 | sys.exit(main()) |