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