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
8import platform
9import sys
10import time
11
12import pluggy
13import py
14import six
15from more_itertools import collapse
16
17import pytest
18from _pytest import nodes
19from _pytest.main import (
20    EXIT_OK,
21    EXIT_TESTSFAILED,
22    EXIT_INTERRUPTED,
23    EXIT_USAGEERROR,
24    EXIT_NOTESTSCOLLECTED,
25)
26
27
28import argparse
29
30
31class MoreQuietAction(argparse.Action):
32    """
33    a modified copy of the argparse count action which counts down and updates
34    the legacy quiet attribute at the same time
35
36    used to unify verbosity handling
37    """
38
39    def __init__(self, option_strings, dest, default=None, required=False, help=None):
40        super(MoreQuietAction, self).__init__(
41            option_strings=option_strings,
42            dest=dest,
43            nargs=0,
44            default=default,
45            required=required,
46            help=help,
47        )
48
49    def __call__(self, parser, namespace, values, option_string=None):
50        new_count = getattr(namespace, self.dest, 0) - 1
51        setattr(namespace, self.dest, new_count)
52        # todo Deprecate config.quiet
53        namespace.quiet = getattr(namespace, "quiet", 0) + 1
54
55
56def pytest_addoption(parser):
57    group = parser.getgroup("terminal reporting", "reporting", after="general")
58    group._addoption(
59        "-v",
60        "--verbose",
61        action="count",
62        default=0,
63        dest="verbose",
64        help="increase verbosity.",
65    ),
66    group._addoption(
67        "-q",
68        "--quiet",
69        action=MoreQuietAction,
70        default=0,
71        dest="verbose",
72        help="decrease verbosity.",
73    ),
74    group._addoption(
75        "--verbosity", dest="verbose", type=int, default=0, help="set verbosity"
76    )
77    group._addoption(
78        "-r",
79        action="store",
80        dest="reportchars",
81        default="",
82        metavar="chars",
83        help="show extra test summary info as specified by chars (f)ailed, "
84        "(E)error, (s)skipped, (x)failed, (X)passed, "
85        "(p)passed, (P)passed with output, (a)all except pP. "
86        "Warnings are displayed at all times except when "
87        "--disable-warnings is set",
88    )
89    group._addoption(
90        "--disable-warnings",
91        "--disable-pytest-warnings",
92        default=False,
93        dest="disable_warnings",
94        action="store_true",
95        help="disable warnings summary",
96    )
97    group._addoption(
98        "-l",
99        "--showlocals",
100        action="store_true",
101        dest="showlocals",
102        default=False,
103        help="show locals in tracebacks (disabled by default).",
104    )
105    group._addoption(
106        "--tb",
107        metavar="style",
108        action="store",
109        dest="tbstyle",
110        default="auto",
111        choices=["auto", "long", "short", "no", "line", "native"],
112        help="traceback print mode (auto/long/short/line/native/no).",
113    )
114    group._addoption(
115        "--show-capture",
116        action="store",
117        dest="showcapture",
118        choices=["no", "stdout", "stderr", "log", "all"],
119        default="all",
120        help="Controls how captured stdout/stderr/log is shown on failed tests. "
121        "Default is 'all'.",
122    )
123    group._addoption(
124        "--fulltrace",
125        "--full-trace",
126        action="store_true",
127        default=False,
128        help="don't cut any tracebacks (default is to cut).",
129    )
130    group._addoption(
131        "--color",
132        metavar="color",
133        action="store",
134        dest="color",
135        default="auto",
136        choices=["yes", "no", "auto"],
137        help="color terminal output (yes/no/auto).",
138    )
139
140    parser.addini(
141        "console_output_style",
142        help="console output: classic or with additional progress information (classic|progress).",
143        default="progress",
144    )
145
146
147def pytest_configure(config):
148    reporter = TerminalReporter(config, sys.stdout)
149    config.pluginmanager.register(reporter, "terminalreporter")
150    if config.option.debug or config.option.traceconfig:
151
152        def mywriter(tags, args):
153            msg = " ".join(map(str, args))
154            reporter.write_line("[traceconfig] " + msg)
155
156        config.trace.root.setprocessor("pytest:config", mywriter)
157
158
159def getreportopt(config):
160    reportopts = ""
161    reportchars = config.option.reportchars
162    if not config.option.disable_warnings and "w" not in reportchars:
163        reportchars += "w"
164    elif config.option.disable_warnings and "w" in reportchars:
165        reportchars = reportchars.replace("w", "")
166    if reportchars:
167        for char in reportchars:
168            if char not in reportopts and char != "a":
169                reportopts += char
170            elif char == "a":
171                reportopts = "fEsxXw"
172    return reportopts
173
174
175def pytest_report_teststatus(report):
176    if report.passed:
177        letter = "."
178    elif report.skipped:
179        letter = "s"
180    elif report.failed:
181        letter = "F"
182        if report.when != "call":
183            letter = "f"
184    return report.outcome, letter, report.outcome.upper()
185
186
187class WarningReport(object):
188    """
189    Simple structure to hold warnings information captured by ``pytest_logwarning``.
190    """
191
192    def __init__(self, code, message, nodeid=None, fslocation=None):
193        """
194        :param code: unused
195        :param str message: user friendly message about the warning
196        :param str|None nodeid: node id that generated the warning (see ``get_location``).
197        :param tuple|py.path.local fslocation:
198            file system location of the source of the warning (see ``get_location``).
199        """
200        self.code = code
201        self.message = message
202        self.nodeid = nodeid
203        self.fslocation = fslocation
204
205    def get_location(self, config):
206        """
207        Returns the more user-friendly information about the location
208        of a warning, or None.
209        """
210        if self.nodeid:
211            return self.nodeid
212        if self.fslocation:
213            if isinstance(self.fslocation, tuple) and len(self.fslocation) >= 2:
214                filename, linenum = self.fslocation[:2]
215                relpath = py.path.local(filename).relto(config.invocation_dir)
216                return "%s:%s" % (relpath, linenum)
217            else:
218                return str(self.fslocation)
219        return None
220
221
222class TerminalReporter(object):
223
224    def __init__(self, config, file=None):
225        import _pytest.config
226
227        self.config = config
228        self.verbosity = self.config.option.verbose
229        self.showheader = self.verbosity >= 0
230        self.showfspath = self.verbosity >= 0
231        self.showlongtestinfo = self.verbosity > 0
232        self._numcollected = 0
233        self._session = None
234
235        self.stats = {}
236        self.startdir = py.path.local()
237        if file is None:
238            file = sys.stdout
239        self._tw = _pytest.config.create_terminal_writer(config, file)
240        # self.writer will be deprecated in pytest-3.4
241        self.writer = self._tw
242        self._screen_width = self._tw.fullwidth
243        self.currentfspath = None
244        self.reportchars = getreportopt(config)
245        self.hasmarkup = self._tw.hasmarkup
246        self.isatty = file.isatty()
247        self._progress_nodeids_reported = set()
248        self._show_progress_info = self._determine_show_progress_info()
249
250    def _determine_show_progress_info(self):
251        """Return True if we should display progress information based on the current config"""
252        # do not show progress if we are not capturing output (#3038)
253        if self.config.getoption("capture") == "no":
254            return False
255        # do not show progress if we are showing fixture setup/teardown
256        if self.config.getoption("setupshow"):
257            return False
258        return self.config.getini("console_output_style") == "progress"
259
260    def hasopt(self, char):
261        char = {"xfailed": "x", "skipped": "s"}.get(char, char)
262        return char in self.reportchars
263
264    def write_fspath_result(self, nodeid, res):
265        fspath = self.config.rootdir.join(nodeid.split("::")[0])
266        if fspath != self.currentfspath:
267            if self.currentfspath is not None:
268                self._write_progress_information_filling_space()
269            self.currentfspath = fspath
270            fspath = self.startdir.bestrelpath(fspath)
271            self._tw.line()
272            self._tw.write(fspath + " ")
273        self._tw.write(res)
274
275    def write_ensure_prefix(self, prefix, extra="", **kwargs):
276        if self.currentfspath != prefix:
277            self._tw.line()
278            self.currentfspath = prefix
279            self._tw.write(prefix)
280        if extra:
281            self._tw.write(extra, **kwargs)
282            self.currentfspath = -2
283
284    def ensure_newline(self):
285        if self.currentfspath:
286            self._tw.line()
287            self.currentfspath = None
288
289    def write(self, content, **markup):
290        self._tw.write(content, **markup)
291
292    def write_line(self, line, **markup):
293        if not isinstance(line, six.text_type):
294            line = six.text_type(line, errors="replace")
295        self.ensure_newline()
296        self._tw.line(line, **markup)
297
298    def rewrite(self, line, **markup):
299        """
300        Rewinds the terminal cursor to the beginning and writes the given line.
301
302        :kwarg erase: if True, will also add spaces until the full terminal width to ensure
303            previous lines are properly erased.
304
305        The rest of the keyword arguments are markup instructions.
306        """
307        erase = markup.pop("erase", False)
308        if erase:
309            fill_count = self._tw.fullwidth - len(line) - 1
310            fill = " " * fill_count
311        else:
312            fill = ""
313        line = str(line)
314        self._tw.write("\r" + line + fill, **markup)
315
316    def write_sep(self, sep, title=None, **markup):
317        self.ensure_newline()
318        self._tw.sep(sep, title, **markup)
319
320    def section(self, title, sep="=", **kw):
321        self._tw.sep(sep, title, **kw)
322
323    def line(self, msg, **kw):
324        self._tw.line(msg, **kw)
325
326    def pytest_internalerror(self, excrepr):
327        for line in six.text_type(excrepr).split("\n"):
328            self.write_line("INTERNALERROR> " + line)
329        return 1
330
331    def pytest_logwarning(self, code, fslocation, message, nodeid):
332        warnings = self.stats.setdefault("warnings", [])
333        warning = WarningReport(
334            code=code, fslocation=fslocation, message=message, nodeid=nodeid
335        )
336        warnings.append(warning)
337
338    def pytest_plugin_registered(self, plugin):
339        if self.config.option.traceconfig:
340            msg = "PLUGIN registered: %s" % (plugin,)
341            # XXX this event may happen during setup/teardown time
342            #     which unfortunately captures our output here
343            #     which garbles our output if we use self.write_line
344            self.write_line(msg)
345
346    def pytest_deselected(self, items):
347        self.stats.setdefault("deselected", []).extend(items)
348
349    def pytest_runtest_logstart(self, nodeid, location):
350        # ensure that the path is printed before the
351        # 1st test of a module starts running
352        if self.showlongtestinfo:
353            line = self._locationline(nodeid, *location)
354            self.write_ensure_prefix(line, "")
355        elif self.showfspath:
356            fsid = nodeid.split("::")[0]
357            self.write_fspath_result(fsid, "")
358
359    def pytest_runtest_logreport(self, report):
360        rep = report
361        res = self.config.hook.pytest_report_teststatus(report=rep)
362        cat, letter, word = res
363        if isinstance(word, tuple):
364            word, markup = word
365        else:
366            markup = None
367        self.stats.setdefault(cat, []).append(rep)
368        self._tests_ran = True
369        if not letter and not word:
370            # probably passed setup/teardown
371            return
372        running_xdist = hasattr(rep, "node")
373        if self.verbosity <= 0:
374            if not running_xdist and self.showfspath:
375                self.write_fspath_result(rep.nodeid, letter)
376            else:
377                self._tw.write(letter)
378        else:
379            self._progress_nodeids_reported.add(rep.nodeid)
380            if markup is None:
381                if rep.passed:
382                    markup = {"green": True}
383                elif rep.failed:
384                    markup = {"red": True}
385                elif rep.skipped:
386                    markup = {"yellow": True}
387                else:
388                    markup = {}
389            line = self._locationline(rep.nodeid, *rep.location)
390            if not running_xdist:
391                self.write_ensure_prefix(line, word, **markup)
392                if self._show_progress_info:
393                    self._write_progress_information_filling_space()
394            else:
395                self.ensure_newline()
396                self._tw.write("[%s]" % rep.node.gateway.id)
397                if self._show_progress_info:
398                    self._tw.write(
399                        self._get_progress_information_message() + " ", cyan=True
400                    )
401                else:
402                    self._tw.write(" ")
403                self._tw.write(word, **markup)
404                self._tw.write(" " + line)
405                self.currentfspath = -2
406
407    def pytest_runtest_logfinish(self, nodeid):
408        if self.verbosity <= 0 and self._show_progress_info:
409            self._progress_nodeids_reported.add(nodeid)
410            last_item = len(
411                self._progress_nodeids_reported
412            ) == self._session.testscollected
413            if last_item:
414                self._write_progress_information_filling_space()
415            else:
416                past_edge = self._tw.chars_on_current_line + self._PROGRESS_LENGTH + 1 >= self._screen_width
417                if past_edge:
418                    msg = self._get_progress_information_message()
419                    self._tw.write(msg + "\n", cyan=True)
420
421    _PROGRESS_LENGTH = len(" [100%]")
422
423    def _get_progress_information_message(self):
424        if self.config.getoption("capture") == "no":
425            return ""
426        collected = self._session.testscollected
427        if collected:
428            progress = len(self._progress_nodeids_reported) * 100 // collected
429            return " [{:3d}%]".format(progress)
430        return " [100%]"
431
432    def _write_progress_information_filling_space(self):
433        msg = self._get_progress_information_message()
434        fill = " " * (
435            self._tw.fullwidth - self._tw.chars_on_current_line - len(msg) - 1
436        )
437        self.write(fill + msg, cyan=True)
438
439    def pytest_collection(self):
440        if not self.isatty and self.config.option.verbose >= 1:
441            self.write("collecting ... ", bold=True)
442
443    def pytest_collectreport(self, report):
444        if report.failed:
445            self.stats.setdefault("error", []).append(report)
446        elif report.skipped:
447            self.stats.setdefault("skipped", []).append(report)
448        items = [x for x in report.result if isinstance(x, pytest.Item)]
449        self._numcollected += len(items)
450        if self.isatty:
451            # self.write_fspath_result(report.nodeid, 'E')
452            self.report_collect()
453
454    def report_collect(self, final=False):
455        if self.config.option.verbose < 0:
456            return
457
458        errors = len(self.stats.get("error", []))
459        skipped = len(self.stats.get("skipped", []))
460        deselected = len(self.stats.get("deselected", []))
461        if final:
462            line = "collected "
463        else:
464            line = "collecting "
465        line += str(self._numcollected) + " item" + (
466            "" if self._numcollected == 1 else "s"
467        )
468        if errors:
469            line += " / %d errors" % errors
470        if deselected:
471            line += " / %d deselected" % deselected
472        if skipped:
473            line += " / %d skipped" % skipped
474        if self.isatty:
475            self.rewrite(line, bold=True, erase=True)
476            if final:
477                self.write("\n")
478        else:
479            self.write_line(line)
480
481    @pytest.hookimpl(trylast=True)
482    def pytest_collection_modifyitems(self):
483        self.report_collect(True)
484
485    @pytest.hookimpl(trylast=True)
486    def pytest_sessionstart(self, session):
487        self._session = session
488        self._sessionstarttime = time.time()
489        if not self.showheader:
490            return
491        self.write_sep("=", "test session starts", bold=True)
492        verinfo = platform.python_version()
493        msg = "platform %s -- Python %s" % (sys.platform, verinfo)
494        if hasattr(sys, "pypy_version_info"):
495            verinfo = ".".join(map(str, sys.pypy_version_info[:3]))
496            msg += "[pypy-%s-%s]" % (verinfo, sys.pypy_version_info[3])
497        msg += ", pytest-%s, py-%s, pluggy-%s" % (
498            pytest.__version__, py.__version__, pluggy.__version__
499        )
500        if (
501            self.verbosity > 0
502            or self.config.option.debug
503            or getattr(self.config.option, "pastebin", None)
504        ):
505            msg += " -- " + str(sys.executable)
506        self.write_line(msg)
507        lines = self.config.hook.pytest_report_header(
508            config=self.config, startdir=self.startdir
509        )
510        self._write_report_lines_from_hooks(lines)
511
512    def _write_report_lines_from_hooks(self, lines):
513        lines.reverse()
514        for line in collapse(lines):
515            self.write_line(line)
516
517    def pytest_report_header(self, config):
518        inifile = ""
519        if config.inifile:
520            inifile = " " + config.rootdir.bestrelpath(config.inifile)
521        lines = ["rootdir: %s, inifile:%s" % (config.rootdir, inifile)]
522
523        plugininfo = config.pluginmanager.list_plugin_distinfo()
524        if plugininfo:
525
526            lines.append("plugins: %s" % ", ".join(_plugin_nameversions(plugininfo)))
527        return lines
528
529    def pytest_collection_finish(self, session):
530        if self.config.option.collectonly:
531            self._printcollecteditems(session.items)
532            if self.stats.get("failed"):
533                self._tw.sep("!", "collection failures")
534                for rep in self.stats.get("failed"):
535                    rep.toterminal(self._tw)
536                return 1
537            return 0
538        lines = self.config.hook.pytest_report_collectionfinish(
539            config=self.config, startdir=self.startdir, items=session.items
540        )
541        self._write_report_lines_from_hooks(lines)
542
543    def _printcollecteditems(self, items):
544        # to print out items and their parent collectors
545        # we take care to leave out Instances aka ()
546        # because later versions are going to get rid of them anyway
547        if self.config.option.verbose < 0:
548            if self.config.option.verbose < -1:
549                counts = {}
550                for item in items:
551                    name = item.nodeid.split("::", 1)[0]
552                    counts[name] = counts.get(name, 0) + 1
553                for name, count in sorted(counts.items()):
554                    self._tw.line("%s: %d" % (name, count))
555            else:
556                for item in items:
557                    nodeid = item.nodeid
558                    nodeid = nodeid.replace("::()::", "::")
559                    self._tw.line(nodeid)
560            return
561        stack = []
562        indent = ""
563        for item in items:
564            needed_collectors = item.listchain()[1:]  # strip root node
565            while stack:
566                if stack == needed_collectors[:len(stack)]:
567                    break
568                stack.pop()
569            for col in needed_collectors[len(stack):]:
570                stack.append(col)
571                # if col.name == "()":
572                #    continue
573                indent = (len(stack) - 1) * "  "
574                self._tw.line("%s%s" % (indent, col))
575
576    @pytest.hookimpl(hookwrapper=True)
577    def pytest_sessionfinish(self, exitstatus):
578        outcome = yield
579        outcome.get_result()
580        self._tw.line("")
581        summary_exit_codes = (
582            EXIT_OK,
583            EXIT_TESTSFAILED,
584            EXIT_INTERRUPTED,
585            EXIT_USAGEERROR,
586            EXIT_NOTESTSCOLLECTED,
587        )
588        if exitstatus in summary_exit_codes:
589            self.config.hook.pytest_terminal_summary(
590                terminalreporter=self, exitstatus=exitstatus
591            )
592        if exitstatus == EXIT_INTERRUPTED:
593            self._report_keyboardinterrupt()
594            del self._keyboardinterrupt_memo
595        self.summary_stats()
596
597    @pytest.hookimpl(hookwrapper=True)
598    def pytest_terminal_summary(self):
599        self.summary_errors()
600        self.summary_failures()
601        yield
602        self.summary_warnings()
603        self.summary_passes()
604
605    def pytest_keyboard_interrupt(self, excinfo):
606        self._keyboardinterrupt_memo = excinfo.getrepr(funcargs=True)
607
608    def pytest_unconfigure(self):
609        if hasattr(self, "_keyboardinterrupt_memo"):
610            self._report_keyboardinterrupt()
611
612    def _report_keyboardinterrupt(self):
613        excrepr = self._keyboardinterrupt_memo
614        msg = excrepr.reprcrash.message
615        self.write_sep("!", msg)
616        if "KeyboardInterrupt" in msg:
617            if self.config.option.fulltrace:
618                excrepr.toterminal(self._tw)
619            else:
620                excrepr.reprcrash.toterminal(self._tw)
621                self._tw.line(
622                    "(to show a full traceback on KeyboardInterrupt use --fulltrace)",
623                    yellow=True,
624                )
625
626    def _locationline(self, nodeid, fspath, lineno, domain):
627
628        def mkrel(nodeid):
629            line = self.config.cwd_relative_nodeid(nodeid)
630            if domain and line.endswith(domain):
631                line = line[:-len(domain)]
632                values = domain.split("[")
633                values[0] = values[0].replace(".", "::")  # don't replace '.' in params
634                line += "[".join(values)
635            return line
636
637        # collect_fspath comes from testid which has a "/"-normalized path
638
639        if fspath:
640            res = mkrel(nodeid).replace("::()", "")  # parens-normalization
641            if nodeid.split("::")[0] != fspath.replace("\\", nodes.SEP):
642                res += " <- " + self.startdir.bestrelpath(fspath)
643        else:
644            res = "[location]"
645        return res + " "
646
647    def _getfailureheadline(self, rep):
648        if hasattr(rep, "location"):
649            fspath, lineno, domain = rep.location
650            return domain
651        else:
652            return "test session"  # XXX?
653
654    def _getcrashline(self, rep):
655        try:
656            return str(rep.longrepr.reprcrash)
657        except AttributeError:
658            try:
659                return str(rep.longrepr)[:50]
660            except AttributeError:
661                return ""
662
663    #
664    # summaries for sessionfinish
665    #
666    def getreports(self, name):
667        values = []
668        for x in self.stats.get(name, []):
669            if not hasattr(x, "_pdbshown"):
670                values.append(x)
671        return values
672
673    def summary_warnings(self):
674        if self.hasopt("w"):
675            all_warnings = self.stats.get("warnings")
676            if not all_warnings:
677                return
678
679            grouped = itertools.groupby(
680                all_warnings, key=lambda wr: wr.get_location(self.config)
681            )
682
683            self.write_sep("=", "warnings summary", yellow=True, bold=False)
684            for location, warning_records in grouped:
685                self._tw.line(str(location) if location else "<undetermined location>")
686                for w in warning_records:
687                    lines = w.message.splitlines()
688                    indented = "\n".join("  " + x for x in lines)
689                    self._tw.line(indented)
690                self._tw.line()
691            self._tw.line("-- Docs: http://doc.pytest.org/en/latest/warnings.html")
692
693    def summary_passes(self):
694        if self.config.option.tbstyle != "no":
695            if self.hasopt("P"):
696                reports = self.getreports("passed")
697                if not reports:
698                    return
699                self.write_sep("=", "PASSES")
700                for rep in reports:
701                    msg = self._getfailureheadline(rep)
702                    self.write_sep("_", msg)
703                    self._outrep_summary(rep)
704
705    def print_teardown_sections(self, rep):
706        for secname, content in rep.sections:
707            if "teardown" in secname:
708                self._tw.sep("-", secname)
709                if content[-1:] == "\n":
710                    content = content[:-1]
711                self._tw.line(content)
712
713    def summary_failures(self):
714        if self.config.option.tbstyle != "no":
715            reports = self.getreports("failed")
716            if not reports:
717                return
718            self.write_sep("=", "FAILURES")
719            for rep in reports:
720                if self.config.option.tbstyle == "line":
721                    line = self._getcrashline(rep)
722                    self.write_line(line)
723                else:
724                    msg = self._getfailureheadline(rep)
725                    markup = {"red": True, "bold": True}
726                    self.write_sep("_", msg, **markup)
727                    self._outrep_summary(rep)
728                    for report in self.getreports(""):
729                        if report.nodeid == rep.nodeid and report.when == "teardown":
730                            self.print_teardown_sections(report)
731
732    def summary_errors(self):
733        if self.config.option.tbstyle != "no":
734            reports = self.getreports("error")
735            if not reports:
736                return
737            self.write_sep("=", "ERRORS")
738            for rep in self.stats["error"]:
739                msg = self._getfailureheadline(rep)
740                if not hasattr(rep, "when"):
741                    # collect
742                    msg = "ERROR collecting " + msg
743                elif rep.when == "setup":
744                    msg = "ERROR at setup of " + msg
745                elif rep.when == "teardown":
746                    msg = "ERROR at teardown of " + msg
747                self.write_sep("_", msg)
748                self._outrep_summary(rep)
749
750    def _outrep_summary(self, rep):
751        rep.toterminal(self._tw)
752        showcapture = self.config.option.showcapture
753        if showcapture == "no":
754            return
755        for secname, content in rep.sections:
756            if showcapture != "all" and showcapture not in secname:
757                continue
758            self._tw.sep("-", secname)
759            if content[-1:] == "\n":
760                content = content[:-1]
761            self._tw.line(content)
762
763    def summary_stats(self):
764        session_duration = time.time() - self._sessionstarttime
765        (line, color) = build_summary_stats_line(self.stats)
766        msg = "%s in %.2f seconds" % (line, session_duration)
767        markup = {color: True, "bold": True}
768
769        if self.verbosity >= 0:
770            self.write_sep("=", msg, **markup)
771        if self.verbosity == -1:
772            self.write_line(msg, **markup)
773
774
775def repr_pythonversion(v=None):
776    if v is None:
777        v = sys.version_info
778    try:
779        return "%s.%s.%s-%s-%s" % v
780    except (TypeError, ValueError):
781        return str(v)
782
783
784def build_summary_stats_line(stats):
785    keys = (
786        "failed passed skipped deselected " "xfailed xpassed warnings error"
787    ).split()
788    unknown_key_seen = False
789    for key in stats.keys():
790        if key not in keys:
791            if key:  # setup/teardown reports have an empty key, ignore them
792                keys.append(key)
793                unknown_key_seen = True
794    parts = []
795    for key in keys:
796        val = stats.get(key, None)
797        if val:
798            parts.append("%d %s" % (len(val), key))
799
800    if parts:
801        line = ", ".join(parts)
802    else:
803        line = "no tests ran"
804
805    if "failed" in stats or "error" in stats:
806        color = "red"
807    elif "warnings" in stats or unknown_key_seen:
808        color = "yellow"
809    elif "passed" in stats:
810        color = "green"
811    else:
812        color = "yellow"
813
814    return (line, color)
815
816
817def _plugin_nameversions(plugininfo):
818    values = []
819    for plugin, dist in plugininfo:
820        # gets us name and version!
821        name = "{dist.project_name}-{dist.version}".format(dist=dist)
822        # questionable convenience, but it keeps things short
823        if name.startswith("pytest-"):
824            name = name[7:]
825        # we decided to print python package names
826        # they can have more than one plugin
827        if name not in values:
828            values.append(name)
829    return values
830