1""" terminal reporting of the full testing process. 2 3This is a good source for looking at the various reporting hooks. 4""" 5from __future__ import absolute_import, division, print_function 6 7import itertools 8from _pytest.main import EXIT_OK, EXIT_TESTSFAILED, EXIT_INTERRUPTED, \ 9 EXIT_USAGEERROR, EXIT_NOTESTSCOLLECTED 10import pytest 11import py 12import sys 13import time 14import platform 15 16import _pytest._pluggy as pluggy 17 18 19def pytest_addoption(parser): 20 group = parser.getgroup("terminal reporting", "reporting", after="general") 21 group._addoption('-v', '--verbose', action="count", 22 dest="verbose", default=0, help="increase verbosity."), 23 group._addoption('-q', '--quiet', action="count", 24 dest="quiet", default=0, help="decrease verbosity."), 25 group._addoption('-r', 26 action="store", dest="reportchars", default='', metavar="chars", 27 help="show extra test summary info as specified by chars (f)ailed, " 28 "(E)error, (s)skipped, (x)failed, (X)passed, " 29 "(p)passed, (P)passed with output, (a)all except pP. " 30 "Warnings are displayed at all times except when " 31 "--disable-warnings is set") 32 group._addoption('--disable-warnings', '--disable-pytest-warnings', default=False, 33 dest='disable_warnings', action='store_true', 34 help='disable warnings summary') 35 group._addoption('-l', '--showlocals', 36 action="store_true", dest="showlocals", default=False, 37 help="show locals in tracebacks (disabled by default).") 38 group._addoption('--tb', metavar="style", 39 action="store", dest="tbstyle", default='auto', 40 choices=['auto', 'long', 'short', 'no', 'line', 'native'], 41 help="traceback print mode (auto/long/short/line/native/no).") 42 group._addoption('--fulltrace', '--full-trace', 43 action="store_true", default=False, 44 help="don't cut any tracebacks (default is to cut).") 45 group._addoption('--color', metavar="color", 46 action="store", dest="color", default='auto', 47 choices=['yes', 'no', 'auto'], 48 help="color terminal output (yes/no/auto).") 49 50def pytest_configure(config): 51 config.option.verbose -= config.option.quiet 52 reporter = TerminalReporter(config, sys.stdout) 53 config.pluginmanager.register(reporter, 'terminalreporter') 54 if config.option.debug or config.option.traceconfig: 55 def mywriter(tags, args): 56 msg = " ".join(map(str, args)) 57 reporter.write_line("[traceconfig] " + msg) 58 config.trace.root.setprocessor("pytest:config", mywriter) 59 60def getreportopt(config): 61 reportopts = "" 62 reportchars = config.option.reportchars 63 if not config.option.disable_warnings and 'w' not in reportchars: 64 reportchars += 'w' 65 elif config.option.disable_warnings and 'w' in reportchars: 66 reportchars = reportchars.replace('w', '') 67 if reportchars: 68 for char in reportchars: 69 if char not in reportopts and char != 'a': 70 reportopts += char 71 elif char == 'a': 72 reportopts = 'fEsxXw' 73 return reportopts 74 75def pytest_report_teststatus(report): 76 if report.passed: 77 letter = "." 78 elif report.skipped: 79 letter = "s" 80 elif report.failed: 81 letter = "F" 82 if report.when != "call": 83 letter = "f" 84 return report.outcome, letter, report.outcome.upper() 85 86 87class WarningReport: 88 """ 89 Simple structure to hold warnings information captured by ``pytest_logwarning``. 90 """ 91 def __init__(self, code, message, nodeid=None, fslocation=None): 92 """ 93 :param code: unused 94 :param str message: user friendly message about the warning 95 :param str|None nodeid: node id that generated the warning (see ``get_location``). 96 :param tuple|py.path.local fslocation: 97 file system location of the source of the warning (see ``get_location``). 98 """ 99 self.code = code 100 self.message = message 101 self.nodeid = nodeid 102 self.fslocation = fslocation 103 104 def get_location(self, config): 105 """ 106 Returns the more user-friendly information about the location 107 of a warning, or None. 108 """ 109 if self.nodeid: 110 return self.nodeid 111 if self.fslocation: 112 if isinstance(self.fslocation, tuple) and len(self.fslocation) >= 2: 113 filename, linenum = self.fslocation[:2] 114 relpath = py.path.local(filename).relto(config.invocation_dir) 115 return '%s:%s' % (relpath, linenum) 116 else: 117 return str(self.fslocation) 118 return None 119 120 121class TerminalReporter: 122 def __init__(self, config, file=None): 123 import _pytest.config 124 self.config = config 125 self.verbosity = self.config.option.verbose 126 self.showheader = self.verbosity >= 0 127 self.showfspath = self.verbosity >= 0 128 self.showlongtestinfo = self.verbosity > 0 129 self._numcollected = 0 130 131 self.stats = {} 132 self.startdir = py.path.local() 133 if file is None: 134 file = sys.stdout 135 self._tw = self.writer = _pytest.config.create_terminal_writer(config, 136 file) 137 self.currentfspath = None 138 self.reportchars = getreportopt(config) 139 self.hasmarkup = self._tw.hasmarkup 140 self.isatty = file.isatty() 141 142 def hasopt(self, char): 143 char = {'xfailed': 'x', 'skipped': 's'}.get(char, char) 144 return char in self.reportchars 145 146 def write_fspath_result(self, nodeid, res): 147 fspath = self.config.rootdir.join(nodeid.split("::")[0]) 148 if fspath != self.currentfspath: 149 self.currentfspath = fspath 150 fspath = self.startdir.bestrelpath(fspath) 151 self._tw.line() 152 self._tw.write(fspath + " ") 153 self._tw.write(res) 154 155 def write_ensure_prefix(self, prefix, extra="", **kwargs): 156 if self.currentfspath != prefix: 157 self._tw.line() 158 self.currentfspath = prefix 159 self._tw.write(prefix) 160 if extra: 161 self._tw.write(extra, **kwargs) 162 self.currentfspath = -2 163 164 def ensure_newline(self): 165 if self.currentfspath: 166 self._tw.line() 167 self.currentfspath = None 168 169 def write(self, content, **markup): 170 self._tw.write(content, **markup) 171 172 def write_line(self, line, **markup): 173 if not py.builtin._istext(line): 174 line = py.builtin.text(line, errors="replace") 175 self.ensure_newline() 176 self._tw.line(line, **markup) 177 178 def rewrite(self, line, **markup): 179 line = str(line) 180 self._tw.write("\r" + line, **markup) 181 182 def write_sep(self, sep, title=None, **markup): 183 self.ensure_newline() 184 self._tw.sep(sep, title, **markup) 185 186 def section(self, title, sep="=", **kw): 187 self._tw.sep(sep, title, **kw) 188 189 def line(self, msg, **kw): 190 self._tw.line(msg, **kw) 191 192 def pytest_internalerror(self, excrepr): 193 for line in py.builtin.text(excrepr).split("\n"): 194 self.write_line("INTERNALERROR> " + line) 195 return 1 196 197 def pytest_logwarning(self, code, fslocation, message, nodeid): 198 warnings = self.stats.setdefault("warnings", []) 199 warning = WarningReport(code=code, fslocation=fslocation, 200 message=message, nodeid=nodeid) 201 warnings.append(warning) 202 203 def pytest_plugin_registered(self, plugin): 204 if self.config.option.traceconfig: 205 msg = "PLUGIN registered: %s" % (plugin,) 206 # XXX this event may happen during setup/teardown time 207 # which unfortunately captures our output here 208 # which garbles our output if we use self.write_line 209 self.write_line(msg) 210 211 def pytest_deselected(self, items): 212 self.stats.setdefault('deselected', []).extend(items) 213 214 def pytest_runtest_logstart(self, nodeid, location): 215 # ensure that the path is printed before the 216 # 1st test of a module starts running 217 if self.showlongtestinfo: 218 line = self._locationline(nodeid, *location) 219 self.write_ensure_prefix(line, "") 220 elif self.showfspath: 221 fsid = nodeid.split("::")[0] 222 self.write_fspath_result(fsid, "") 223 224 def pytest_runtest_logreport(self, report): 225 rep = report 226 res = self.config.hook.pytest_report_teststatus(report=rep) 227 cat, letter, word = res 228 self.stats.setdefault(cat, []).append(rep) 229 self._tests_ran = True 230 if not letter and not word: 231 # probably passed setup/teardown 232 return 233 if self.verbosity <= 0: 234 if not hasattr(rep, 'node') and self.showfspath: 235 self.write_fspath_result(rep.nodeid, letter) 236 else: 237 self._tw.write(letter) 238 else: 239 if isinstance(word, tuple): 240 word, markup = word 241 else: 242 if rep.passed: 243 markup = {'green':True} 244 elif rep.failed: 245 markup = {'red':True} 246 elif rep.skipped: 247 markup = {'yellow':True} 248 line = self._locationline(rep.nodeid, *rep.location) 249 if not hasattr(rep, 'node'): 250 self.write_ensure_prefix(line, word, **markup) 251 #self._tw.write(word, **markup) 252 else: 253 self.ensure_newline() 254 if hasattr(rep, 'node'): 255 self._tw.write("[%s] " % rep.node.gateway.id) 256 self._tw.write(word, **markup) 257 self._tw.write(" " + line) 258 self.currentfspath = -2 259 260 def pytest_collection(self): 261 if not self.isatty and self.config.option.verbose >= 1: 262 self.write("collecting ... ", bold=True) 263 264 def pytest_collectreport(self, report): 265 if report.failed: 266 self.stats.setdefault("error", []).append(report) 267 elif report.skipped: 268 self.stats.setdefault("skipped", []).append(report) 269 items = [x for x in report.result if isinstance(x, pytest.Item)] 270 self._numcollected += len(items) 271 if self.isatty: 272 #self.write_fspath_result(report.nodeid, 'E') 273 self.report_collect() 274 275 def report_collect(self, final=False): 276 if self.config.option.verbose < 0: 277 return 278 279 errors = len(self.stats.get('error', [])) 280 skipped = len(self.stats.get('skipped', [])) 281 if final: 282 line = "collected " 283 else: 284 line = "collecting " 285 line += str(self._numcollected) + " item" + ('' if self._numcollected == 1 else 's') 286 if errors: 287 line += " / %d errors" % errors 288 if skipped: 289 line += " / %d skipped" % skipped 290 if self.isatty: 291 if final: 292 line += " \n" 293 self.rewrite(line, bold=True) 294 else: 295 self.write_line(line) 296 297 def pytest_collection_modifyitems(self): 298 self.report_collect(True) 299 300 @pytest.hookimpl(trylast=True) 301 def pytest_sessionstart(self, session): 302 self._sessionstarttime = time.time() 303 if not self.showheader: 304 return 305 self.write_sep("=", "test session starts", bold=True) 306 verinfo = platform.python_version() 307 msg = "platform %s -- Python %s" % (sys.platform, verinfo) 308 if hasattr(sys, 'pypy_version_info'): 309 verinfo = ".".join(map(str, sys.pypy_version_info[:3])) 310 msg += "[pypy-%s-%s]" % (verinfo, sys.pypy_version_info[3]) 311 msg += ", pytest-%s, py-%s, pluggy-%s" % ( 312 pytest.__version__, py.__version__, pluggy.__version__) 313 if self.verbosity > 0 or self.config.option.debug or \ 314 getattr(self.config.option, 'pastebin', None): 315 msg += " -- " + str(sys.executable) 316 self.write_line(msg) 317 lines = self.config.hook.pytest_report_header( 318 config=self.config, startdir=self.startdir) 319 lines.reverse() 320 for line in flatten(lines): 321 self.write_line(line) 322 323 def pytest_report_header(self, config): 324 inifile = "" 325 if config.inifile: 326 inifile = " " + config.rootdir.bestrelpath(config.inifile) 327 lines = ["rootdir: %s, inifile:%s" % (config.rootdir, inifile)] 328 329 plugininfo = config.pluginmanager.list_plugin_distinfo() 330 if plugininfo: 331 332 lines.append( 333 "plugins: %s" % ", ".join(_plugin_nameversions(plugininfo))) 334 return lines 335 336 def pytest_collection_finish(self, session): 337 if self.config.option.collectonly: 338 self._printcollecteditems(session.items) 339 if self.stats.get('failed'): 340 self._tw.sep("!", "collection failures") 341 for rep in self.stats.get('failed'): 342 rep.toterminal(self._tw) 343 return 1 344 return 0 345 if not self.showheader: 346 return 347 #for i, testarg in enumerate(self.config.args): 348 # self.write_line("test path %d: %s" %(i+1, testarg)) 349 350 def _printcollecteditems(self, items): 351 # to print out items and their parent collectors 352 # we take care to leave out Instances aka () 353 # because later versions are going to get rid of them anyway 354 if self.config.option.verbose < 0: 355 if self.config.option.verbose < -1: 356 counts = {} 357 for item in items: 358 name = item.nodeid.split('::', 1)[0] 359 counts[name] = counts.get(name, 0) + 1 360 for name, count in sorted(counts.items()): 361 self._tw.line("%s: %d" % (name, count)) 362 else: 363 for item in items: 364 nodeid = item.nodeid 365 nodeid = nodeid.replace("::()::", "::") 366 self._tw.line(nodeid) 367 return 368 stack = [] 369 indent = "" 370 for item in items: 371 needed_collectors = item.listchain()[1:] # strip root node 372 while stack: 373 if stack == needed_collectors[:len(stack)]: 374 break 375 stack.pop() 376 for col in needed_collectors[len(stack):]: 377 stack.append(col) 378 #if col.name == "()": 379 # continue 380 indent = (len(stack) - 1) * " " 381 self._tw.line("%s%s" % (indent, col)) 382 383 @pytest.hookimpl(hookwrapper=True) 384 def pytest_sessionfinish(self, exitstatus): 385 outcome = yield 386 outcome.get_result() 387 self._tw.line("") 388 summary_exit_codes = ( 389 EXIT_OK, EXIT_TESTSFAILED, EXIT_INTERRUPTED, EXIT_USAGEERROR, 390 EXIT_NOTESTSCOLLECTED) 391 if exitstatus in summary_exit_codes: 392 self.config.hook.pytest_terminal_summary(terminalreporter=self, 393 exitstatus=exitstatus) 394 self.summary_errors() 395 self.summary_failures() 396 self.summary_warnings() 397 self.summary_passes() 398 if exitstatus == EXIT_INTERRUPTED: 399 self._report_keyboardinterrupt() 400 del self._keyboardinterrupt_memo 401 self.summary_deselected() 402 self.summary_stats() 403 404 def pytest_keyboard_interrupt(self, excinfo): 405 self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True) 406 407 def pytest_unconfigure(self): 408 if hasattr(self, '_keyboardinterrupt_memo'): 409 self._report_keyboardinterrupt() 410 411 def _report_keyboardinterrupt(self): 412 excrepr = self._keyboardinterrupt_memo 413 msg = excrepr.reprcrash.message 414 self.write_sep("!", msg) 415 if "KeyboardInterrupt" in msg: 416 if self.config.option.fulltrace: 417 excrepr.toterminal(self._tw) 418 else: 419 self._tw.line("to show a full traceback on KeyboardInterrupt use --fulltrace", yellow=True) 420 excrepr.reprcrash.toterminal(self._tw) 421 422 def _locationline(self, nodeid, fspath, lineno, domain): 423 def mkrel(nodeid): 424 line = self.config.cwd_relative_nodeid(nodeid) 425 if domain and line.endswith(domain): 426 line = line[:-len(domain)] 427 l = domain.split("[") 428 l[0] = l[0].replace('.', '::') # don't replace '.' in params 429 line += "[".join(l) 430 return line 431 # collect_fspath comes from testid which has a "/"-normalized path 432 433 if fspath: 434 res = mkrel(nodeid).replace("::()", "") # parens-normalization 435 if nodeid.split("::")[0] != fspath.replace("\\", "/"): 436 res += " <- " + self.startdir.bestrelpath(fspath) 437 else: 438 res = "[location]" 439 return res + " " 440 441 def _getfailureheadline(self, rep): 442 if hasattr(rep, 'location'): 443 fspath, lineno, domain = rep.location 444 return domain 445 else: 446 return "test session" # XXX? 447 448 def _getcrashline(self, rep): 449 try: 450 return str(rep.longrepr.reprcrash) 451 except AttributeError: 452 try: 453 return str(rep.longrepr)[:50] 454 except AttributeError: 455 return "" 456 457 # 458 # summaries for sessionfinish 459 # 460 def getreports(self, name): 461 l = [] 462 for x in self.stats.get(name, []): 463 if not hasattr(x, '_pdbshown'): 464 l.append(x) 465 return l 466 467 def summary_warnings(self): 468 if self.hasopt("w"): 469 all_warnings = self.stats.get("warnings") 470 if not all_warnings: 471 return 472 473 grouped = itertools.groupby(all_warnings, key=lambda wr: wr.get_location(self.config)) 474 475 self.write_sep("=", "warnings summary", yellow=True, bold=False) 476 for location, warnings in grouped: 477 self._tw.line(str(location) or '<undetermined location>') 478 for w in warnings: 479 lines = w.message.splitlines() 480 indented = '\n'.join(' ' + x for x in lines) 481 self._tw.line(indented) 482 self._tw.line() 483 self._tw.line('-- Docs: http://doc.pytest.org/en/latest/warnings.html') 484 485 def summary_passes(self): 486 if self.config.option.tbstyle != "no": 487 if self.hasopt("P"): 488 reports = self.getreports('passed') 489 if not reports: 490 return 491 self.write_sep("=", "PASSES") 492 for rep in reports: 493 msg = self._getfailureheadline(rep) 494 self.write_sep("_", msg) 495 self._outrep_summary(rep) 496 497 def print_teardown_sections(self, rep): 498 for secname, content in rep.sections: 499 if 'teardown' in secname: 500 self._tw.sep('-', secname) 501 if content[-1:] == "\n": 502 content = content[:-1] 503 self._tw.line(content) 504 505 506 def summary_failures(self): 507 if self.config.option.tbstyle != "no": 508 reports = self.getreports('failed') 509 if not reports: 510 return 511 self.write_sep("=", "FAILURES") 512 for rep in reports: 513 if self.config.option.tbstyle == "line": 514 line = self._getcrashline(rep) 515 self.write_line(line) 516 else: 517 msg = self._getfailureheadline(rep) 518 markup = {'red': True, 'bold': True} 519 self.write_sep("_", msg, **markup) 520 self._outrep_summary(rep) 521 for report in self.getreports(''): 522 if report.nodeid == rep.nodeid and report.when == 'teardown': 523 self.print_teardown_sections(report) 524 525 def summary_errors(self): 526 if self.config.option.tbstyle != "no": 527 reports = self.getreports('error') 528 if not reports: 529 return 530 self.write_sep("=", "ERRORS") 531 for rep in self.stats['error']: 532 msg = self._getfailureheadline(rep) 533 if not hasattr(rep, 'when'): 534 # collect 535 msg = "ERROR collecting " + msg 536 elif rep.when == "setup": 537 msg = "ERROR at setup of " + msg 538 elif rep.when == "teardown": 539 msg = "ERROR at teardown of " + msg 540 self.write_sep("_", msg) 541 self._outrep_summary(rep) 542 543 def _outrep_summary(self, rep): 544 rep.toterminal(self._tw) 545 for secname, content in rep.sections: 546 self._tw.sep("-", secname) 547 if content[-1:] == "\n": 548 content = content[:-1] 549 self._tw.line(content) 550 551 def summary_stats(self): 552 session_duration = time.time() - self._sessionstarttime 553 (line, color) = build_summary_stats_line(self.stats) 554 msg = "%s in %.2f seconds" % (line, session_duration) 555 markup = {color: True, 'bold': True} 556 557 if self.verbosity >= 0: 558 self.write_sep("=", msg, **markup) 559 if self.verbosity == -1: 560 self.write_line(msg, **markup) 561 562 def summary_deselected(self): 563 if 'deselected' in self.stats: 564 self.write_sep("=", "%d tests deselected" % ( 565 len(self.stats['deselected'])), bold=True) 566 567def repr_pythonversion(v=None): 568 if v is None: 569 v = sys.version_info 570 try: 571 return "%s.%s.%s-%s-%s" % v 572 except (TypeError, ValueError): 573 return str(v) 574 575def flatten(l): 576 for x in l: 577 if isinstance(x, (list, tuple)): 578 for y in flatten(x): 579 yield y 580 else: 581 yield x 582 583def build_summary_stats_line(stats): 584 keys = ("failed passed skipped deselected " 585 "xfailed xpassed warnings error").split() 586 unknown_key_seen = False 587 for key in stats.keys(): 588 if key not in keys: 589 if key: # setup/teardown reports have an empty key, ignore them 590 keys.append(key) 591 unknown_key_seen = True 592 parts = [] 593 for key in keys: 594 val = stats.get(key, None) 595 if val: 596 parts.append("%d %s" % (len(val), key)) 597 598 if parts: 599 line = ", ".join(parts) 600 else: 601 line = "no tests ran" 602 603 if 'failed' in stats or 'error' in stats: 604 color = 'red' 605 elif 'warnings' in stats or unknown_key_seen: 606 color = 'yellow' 607 elif 'passed' in stats: 608 color = 'green' 609 else: 610 color = 'yellow' 611 612 return (line, color) 613 614 615def _plugin_nameversions(plugininfo): 616 l = [] 617 for plugin, dist in plugininfo: 618 # gets us name and version! 619 name = '{dist.project_name}-{dist.version}'.format(dist=dist) 620 # questionable convenience, but it keeps things short 621 if name.startswith("pytest-"): 622 name = name[7:] 623 # we decided to print python package names 624 # they can have more than one plugin 625 if name not in l: 626 l.append(name) 627 return l 628