From d47c7cbff37d4b20c3f0e191a7de6e4173f61a13 Mon Sep 17 00:00:00 2001 From: Gleb Nikonorov Date: Fri, 18 Dec 2020 17:54:41 -0500 Subject: [PATCH] split plugin.py into smaller files (#427) --- src/pytest_html/html_report.py | 329 +++++++++++++++++ src/pytest_html/outcome.py | 33 ++ src/pytest_html/plugin.py | 654 +-------------------------------- src/pytest_html/result.py | 287 +++++++++++++++ src/pytest_html/util.py | 12 + testing/test_pytest_html.py | 7 +- 6 files changed, 667 insertions(+), 655 deletions(-) create mode 100644 src/pytest_html/html_report.py create mode 100644 src/pytest_html/outcome.py create mode 100644 src/pytest_html/result.py create mode 100644 src/pytest_html/util.py diff --git a/src/pytest_html/html_report.py b/src/pytest_html/html_report.py new file mode 100644 index 0000000..e1aa397 --- /dev/null +++ b/src/pytest_html/html_report.py @@ -0,0 +1,329 @@ +import bisect +import datetime +import json +import os +import time +from collections import defaultdict +from collections import OrderedDict + +from py.xml import html +from py.xml import raw + +from . import __pypi_url__ +from . import __version__ +from .outcome import Outcome +from .result import TestResult +from .util import ansi_support + + +class HTMLReport: + def __init__(self, logfile, config): + logfile = os.path.expanduser(os.path.expandvars(logfile)) + self.logfile = os.path.abspath(logfile) + self.test_logs = [] + self.title = os.path.basename(self.logfile) + self.results = [] + self.errors = self.failed = 0 + self.passed = self.skipped = 0 + self.xfailed = self.xpassed = 0 + has_rerun = config.pluginmanager.hasplugin("rerunfailures") + self.rerun = 0 if has_rerun else None + self.self_contained = config.getoption("self_contained_html") + self.config = config + self.reports = defaultdict(list) + + def _appendrow(self, outcome, report): + result = TestResult(outcome, report, self.logfile, self.config) + if result.row_table is not None: + index = bisect.bisect_right(self.results, result) + self.results.insert(index, result) + tbody = html.tbody( + result.row_table, + class_="{} results-table-row".format(result.outcome.lower()), + ) + if result.row_extra is not None: + tbody.append(result.row_extra) + self.test_logs.insert(index, tbody) + + def append_passed(self, report): + if report.when == "call": + if hasattr(report, "wasxfail"): + self.xpassed += 1 + self._appendrow("XPassed", report) + else: + self.passed += 1 + self._appendrow("Passed", report) + + def append_failed(self, report): + if getattr(report, "when", None) == "call": + if hasattr(report, "wasxfail"): + # pytest < 3.0 marked xpasses as failures + self.xpassed += 1 + self._appendrow("XPassed", report) + else: + self.failed += 1 + self._appendrow("Failed", report) + else: + self.errors += 1 + self._appendrow("Error", report) + + def append_rerun(self, report): + self.rerun += 1 + self._appendrow("Rerun", report) + + def append_skipped(self, report): + if hasattr(report, "wasxfail"): + self.xfailed += 1 + self._appendrow("XFailed", report) + else: + self.skipped += 1 + self._appendrow("Skipped", report) + + def _generate_report(self, session): + suite_stop_time = time.time() + suite_time_delta = suite_stop_time - self.suite_start_time + numtests = self.passed + self.failed + self.xpassed + self.xfailed + generated = datetime.datetime.now() + + with open( + os.path.join(os.path.dirname(__file__), "resources", "style.css") + ) as style_css_fp: + self.style_css = style_css_fp.read() + + if ansi_support(): + ansi_css = [ + "\n/******************************", + " * ANSI2HTML STYLES", + " ******************************/\n", + ] + ansi_css.extend([str(r) for r in ansi_support().style.get_styles()]) + self.style_css += "\n".join(ansi_css) + + # Add user-provided CSS + for path in self.config.getoption("css"): + self.style_css += "\n/******************************" + self.style_css += "\n * CUSTOM CSS" + self.style_css += f"\n * {path}" + self.style_css += "\n ******************************/\n\n" + with open(path) as f: + self.style_css += f.read() + + css_href = "assets/style.css" + html_css = html.link(href=css_href, rel="stylesheet", type="text/css") + if self.self_contained: + html_css = html.style(raw(self.style_css)) + + session.config.hook.pytest_html_report_title(report=self) + + head = html.head(html.meta(charset="utf-8"), html.title(self.title), html_css) + + outcomes = [ + Outcome("passed", self.passed), + Outcome("skipped", self.skipped), + Outcome("failed", self.failed), + Outcome("error", self.errors, label="errors"), + Outcome("xfailed", self.xfailed, label="expected failures"), + Outcome("xpassed", self.xpassed, label="unexpected passes"), + ] + + if self.rerun is not None: + outcomes.append(Outcome("rerun", self.rerun)) + + summary = [ + html.p(f"{numtests} tests ran in {suite_time_delta:.2f} seconds. "), + html.p( + "(Un)check the boxes to filter the results.", + class_="filter", + hidden="true", + ), + ] + + for i, outcome in enumerate(outcomes, start=1): + summary.append(outcome.checkbox) + summary.append(outcome.summary_item) + if i < len(outcomes): + summary.append(", ") + + cells = [ + html.th("Result", class_="sortable result initial-sort", col="result"), + html.th("Test", class_="sortable", col="name"), + html.th("Duration", class_="sortable", col="duration"), + html.th("Links", class_="sortable links", col="links"), + ] + session.config.hook.pytest_html_results_table_header(cells=cells) + + results = [ + html.h2("Results"), + html.table( + [ + html.thead( + html.tr(cells), + html.tr( + [ + html.th( + "No results found. Try to check the filters", + colspan=len(cells), + ) + ], + id="not-found-message", + hidden="true", + ), + id="results-table-head", + ), + self.test_logs, + ], + id="results-table", + ), + ] + + with open( + os.path.join(os.path.dirname(__file__), "resources", "main.js") + ) as main_js_fp: + main_js = main_js_fp.read() + + body = html.body( + html.script(raw(main_js)), + html.h1(self.title), + html.p( + "Report generated on {} at {} by ".format( + generated.strftime("%d-%b-%Y"), generated.strftime("%H:%M:%S") + ), + html.a("pytest-html", href=__pypi_url__), + f" v{__version__}", + ), + onLoad="init()", + ) + + body.extend(self._generate_environment(session.config)) + + summary_prefix, summary_postfix = [], [] + session.config.hook.pytest_html_results_summary( + prefix=summary_prefix, summary=summary, postfix=summary_postfix + ) + body.extend([html.h2("Summary")] + summary_prefix + summary + summary_postfix) + + body.extend(results) + + doc = html.html(head, body) + + unicode_doc = "\n{}".format(doc.unicode(indent=2)) + + # Fix encoding issues, e.g. with surrogates + unicode_doc = unicode_doc.encode("utf-8", errors="xmlcharrefreplace") + return unicode_doc.decode("utf-8") + + def _generate_environment(self, config): + if not hasattr(config, "_metadata") or config._metadata is None: + return [] + + metadata = config._metadata + environment = [html.h2("Environment")] + rows = [] + + keys = [k for k in metadata.keys()] + if not isinstance(metadata, OrderedDict): + keys.sort() + + for key in keys: + value = metadata[key] + if isinstance(value, str) and value.startswith("http"): + value = html.a(value, href=value, target="_blank") + elif isinstance(value, (list, tuple, set)): + value = ", ".join(str(i) for i in sorted(map(str, value))) + elif isinstance(value, dict): + sorted_dict = {k: value[k] for k in sorted(value)} + value = json.dumps(sorted_dict) + raw_value_string = raw(str(value)) + rows.append(html.tr(html.td(key), html.td(raw_value_string))) + + environment.append(html.table(rows, id="environment")) + return environment + + def _save_report(self, report_content): + dir_name = os.path.dirname(self.logfile) + assets_dir = os.path.join(dir_name, "assets") + + os.makedirs(dir_name, exist_ok=True) + if not self.self_contained: + os.makedirs(assets_dir, exist_ok=True) + + with open(self.logfile, "w", encoding="utf-8") as f: + f.write(report_content) + if not self.self_contained: + style_path = os.path.join(assets_dir, "style.css") + with open(style_path, "w", encoding="utf-8") as f: + f.write(self.style_css) + + def _post_process_reports(self): + for test_name, test_reports in self.reports.items(): + report_outcome = "passed" + wasxfail = False + failure_when = None + full_text = "" + extras = [] + duration = 0.0 + + # in theory the last one should have all logs so we just go + # through them all to figure out the outcome, xfail, duration, + # extras, and when it swapped from pass + for test_report in test_reports: + if test_report.outcome == "rerun": + # reruns are separate test runs for all intensive purposes + self.append_rerun(test_report) + else: + full_text += test_report.longreprtext + extras.extend(getattr(test_report, "extra", [])) + duration += getattr(test_report, "duration", 0.0) + + if ( + test_report.outcome not in ("passed", "rerun") + and report_outcome == "passed" + ): + report_outcome = test_report.outcome + failure_when = test_report.when + + if hasattr(test_report, "wasxfail"): + wasxfail = True + + # the following test_report. = settings come at the end of us + # looping through all test_reports that make up a single + # case. + + # outcome on the right comes from the outcome of the various + # test_reports that make up this test case + # we are just carrying it over to the final report. + test_report.outcome = report_outcome + test_report.when = "call" + test_report.nodeid = test_name + test_report.longrepr = full_text + test_report.extra = extras + test_report.duration = duration + + if wasxfail: + test_report.wasxfail = True + + if test_report.outcome == "passed": + self.append_passed(test_report) + elif test_report.outcome == "skipped": + self.append_skipped(test_report) + elif test_report.outcome == "failed": + test_report.when = failure_when + self.append_failed(test_report) + + def pytest_runtest_logreport(self, report): + self.reports[report.nodeid].append(report) + + def pytest_collectreport(self, report): + if report.failed: + self.append_failed(report) + + def pytest_sessionstart(self, session): + self.suite_start_time = time.time() + + def pytest_sessionfinish(self, session): + self._post_process_reports() + report_content = self._generate_report(session) + self._save_report(report_content) + + def pytest_terminal_summary(self, terminalreporter): + terminalreporter.write_sep("-", f"generated html file: file://{self.logfile}") diff --git a/src/pytest_html/outcome.py b/src/pytest_html/outcome.py new file mode 100644 index 0000000..1bb71ac --- /dev/null +++ b/src/pytest_html/outcome.py @@ -0,0 +1,33 @@ +from py.xml import html + + +class Outcome: + def __init__(self, outcome, total=0, label=None, test_result=None, class_html=None): + self.outcome = outcome + self.label = label or outcome + self.class_html = class_html or outcome + self.total = total + self.test_result = test_result or outcome + + self.generate_checkbox() + self.generate_summary_item() + + def generate_checkbox(self): + checkbox_kwargs = {"data-test-result": self.test_result.lower()} + if self.total == 0: + checkbox_kwargs["disabled"] = "true" + + self.checkbox = html.input( + type="checkbox", + checked="true", + onChange="filterTable(this)", + name="filter_checkbox", + class_="filter", + hidden="true", + **checkbox_kwargs, + ) + + def generate_summary_item(self): + self.summary_item = html.span( + f"{self.total} {self.label}", class_=self.class_html + ) diff --git a/src/pytest_html/plugin.py b/src/pytest_html/plugin.py index 93bd4bf..9220919 100644 --- a/src/pytest_html/plugin.py +++ b/src/pytest_html/plugin.py @@ -1,42 +1,12 @@ # This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at http://mozilla.org/MPL/2.0/. -import bisect -import datetime -import importlib -import json import os -import re -import time -import warnings -from base64 import b64decode -from base64 import b64encode -from collections import defaultdict -from collections import OrderedDict -from functools import lru_cache -from html import escape -from os.path import isfile import pytest -from _pytest.logging import _remove_ansi_escape_sequences -from _pytest.pathlib import Path -from py.xml import html -from py.xml import raw -from . import __pypi_url__ -from . import __version__ -from . import extras -from . import nextgen - - -@lru_cache() -def ansi_support(): - try: - # from ansi2html import Ansi2HTMLConverter, style # NOQA - return importlib.import_module("ansi2html") - except ImportError: - # ansi2html is not installed - pass +from . import extras # noqa: F401 +from .html_report import HTMLReport def pytest_addhooks(pluginmanager): @@ -145,623 +115,3 @@ def extra(pytestconfig): pytestconfig.extras = [] yield pytestconfig.extras del pytestconfig.extras[:] - - -def data_uri(content, mime_type="text/plain", charset="utf-8"): - data = b64encode(content.encode(charset)).decode("ascii") - return f"data:{mime_type};charset={charset};base64,{data}" - - -class HTMLReport: - def __init__(self, logfile, config): - logfile = os.path.expanduser(os.path.expandvars(logfile)) - self.logfile = os.path.abspath(logfile) - self.test_logs = [] - self.title = os.path.basename(self.logfile) - self.results = [] - self.errors = self.failed = 0 - self.passed = self.skipped = 0 - self.xfailed = self.xpassed = 0 - has_rerun = config.pluginmanager.hasplugin("rerunfailures") - self.rerun = 0 if has_rerun else None - self.self_contained = config.getoption("self_contained_html") - self.config = config - self.reports = defaultdict(list) - - class TestResult: - def __init__(self, outcome, report, logfile, config): - self.test_id = report.nodeid.encode("utf-8").decode("unicode_escape") - if getattr(report, "when", "call") != "call": - self.test_id = "::".join([report.nodeid, report.when]) - self.time = getattr(report, "duration", 0.0) - self.formatted_time = self._format_time(report) - self.outcome = outcome - self.additional_html = [] - self.links_html = [] - self.self_contained = config.getoption("self_contained_html") - self.max_asset_filename_length = int( - config.getini("max_asset_filename_length") - ) - self.logfile = logfile - self.config = config - self.row_table = self.row_extra = None - - test_index = hasattr(report, "rerun") and report.rerun + 1 or 0 - - for extra_index, extra in enumerate(getattr(report, "extra", [])): - self.append_extra_html(extra, extra_index, test_index) - - self.append_log_html( - report, - self.additional_html, - config.option.capture, - config.option.showcapture, - ) - - cells = [ - html.td(self.outcome, class_="col-result"), - html.td(self.test_id, class_="col-name"), - html.td(self.formatted_time, class_="col-duration"), - html.td(self.links_html, class_="col-links"), - ] - - self.config.hook.pytest_html_results_table_row(report=report, cells=cells) - - self.config.hook.pytest_html_results_table_html( - report=report, data=self.additional_html - ) - - if len(cells) > 0: - tr_class = None - if self.config.getini("render_collapsed"): - tr_class = "collapsed" - self.row_table = html.tr(cells) - self.row_extra = html.tr( - html.td(self.additional_html, class_="extra", colspan=len(cells)), - class_=tr_class, - ) - - def __lt__(self, other): - order = ( - "Error", - "Failed", - "Rerun", - "XFailed", - "XPassed", - "Skipped", - "Passed", - ) - return order.index(self.outcome) < order.index(other.outcome) - - def create_asset( - self, content, extra_index, test_index, file_extension, mode="w" - ): - asset_file_name = "{}_{}_{}.{}".format( - re.sub(r"[^\w\.]", "_", self.test_id), - str(extra_index), - str(test_index), - file_extension, - )[-self.max_asset_filename_length :] - asset_path = os.path.join( - os.path.dirname(self.logfile), "assets", asset_file_name - ) - - os.makedirs(os.path.dirname(asset_path), exist_ok=True) - - relative_path = f"assets/{asset_file_name}" - - kwargs = {"encoding": "utf-8"} if "b" not in mode else {} - with open(asset_path, mode, **kwargs) as f: - f.write(content) - return relative_path - - def append_extra_html(self, extra, extra_index, test_index): - href = None - if extra.get("format_type") == extras.FORMAT_IMAGE: - self._append_image(extra, extra_index, test_index) - - elif extra.get("format_type") == extras.FORMAT_HTML: - self.additional_html.append(html.div(raw(extra.get("content")))) - - elif extra.get("format_type") == extras.FORMAT_JSON: - content = json.dumps(extra.get("content")) - if self.self_contained: - href = data_uri(content, mime_type=extra.get("mime_type")) - else: - href = self.create_asset( - content, extra_index, test_index, extra.get("extension") - ) - - elif extra.get("format_type") == extras.FORMAT_TEXT: - content = extra.get("content") - if isinstance(content, bytes): - content = content.decode("utf-8") - if self.self_contained: - href = data_uri(content) - else: - href = self.create_asset( - content, extra_index, test_index, extra.get("extension") - ) - - elif extra.get("format_type") == extras.FORMAT_URL: - href = extra.get("content") - - elif extra.get("format_type") == extras.FORMAT_VIDEO: - self._append_video(extra, extra_index, test_index) - - if href is not None: - self.links_html.append( - html.a( - extra.get("name"), - class_=extra.get("format_type"), - href=href, - target="_blank", - ) - ) - self.links_html.append(" ") - - def _format_time(self, report): - # parse the report duration into its display version and return - # it to the caller - duration = getattr(report, "duration", None) - if duration is None: - return "" - - duration_formatter = getattr(report, "duration_formatter", None) - string_duration = str(duration) - if duration_formatter is None: - if "." in string_duration: - split_duration = string_duration.split(".") - split_duration[1] = split_duration[1][0:2] - - string_duration = ".".join(split_duration) - - return string_duration - else: - # support %f, since time.strftime doesn't support it out of the box - # keep a precision of 2 for legacy reasons - formatted_milliseconds = "00" - if "." in string_duration: - milliseconds = string_duration.split(".")[1] - formatted_milliseconds = milliseconds[0:2] - - duration_formatter = duration_formatter.replace( - "%f", formatted_milliseconds - ) - duration_as_gmtime = time.gmtime(report.duration) - return time.strftime(duration_formatter, duration_as_gmtime) - - def _populate_html_log_div(self, log, report): - if report.longrepr: - # longreprtext is only filled out on failure by pytest - # otherwise will be None. - # Use full_text if longreprtext is None-ish - # we added full_text elsewhere in this file. - text = report.longreprtext or report.full_text - for line in text.splitlines(): - separator = line.startswith("_ " * 10) - if separator: - log.append(line[:80]) - else: - exception = line.startswith("E ") - if exception: - log.append(html.span(raw(escape(line)), class_="error")) - else: - log.append(raw(escape(line))) - log.append(html.br()) - - for section in report.sections: - header, content = map(escape, section) - log.append(f" {header:-^80} ") - log.append(html.br()) - - if ansi_support(): - converter = ansi_support().Ansi2HTMLConverter( - inline=False, escaped=False - ) - content = converter.convert(content, full=False) - else: - content = _remove_ansi_escape_sequences(content) - - log.append(raw(content)) - log.append(html.br()) - - def append_log_html( - self, - report, - additional_html, - pytest_capture_value, - pytest_show_capture_value, - ): - log = html.div(class_="log") - - should_skip_captured_output = pytest_capture_value == "no" - if report.outcome == "failed" and not should_skip_captured_output: - should_skip_captured_output = pytest_show_capture_value == "no" - if not should_skip_captured_output: - self._populate_html_log_div(log, report) - - if len(log) == 0: - log = html.div(class_="empty log") - log.append("No log output captured.") - - additional_html.append(log) - - def _make_media_html_div( - self, extra, extra_index, test_index, base_extra_string, base_extra_class - ): - content = extra.get("content") - try: - is_uri_or_path = content.startswith(("file", "http")) or isfile(content) - except ValueError: - # On Windows, os.path.isfile throws this exception when - # passed a b64 encoded image. - is_uri_or_path = False - if is_uri_or_path: - if self.self_contained: - warnings.warn( - "Self-contained HTML report " - "includes link to external " - f"resource: {content}" - ) - - html_div = html.a( - raw(base_extra_string.format(extra.get("content"))), href=content - ) - elif self.self_contained: - src = f"data:{extra.get('mime_type')};base64,{content}" - html_div = raw(base_extra_string.format(src)) - else: - content = b64decode(content.encode("utf-8")) - href = src = self.create_asset( - content, extra_index, test_index, extra.get("extension"), "wb" - ) - html_div = html.a( - raw(base_extra_string.format(src)), - class_=base_extra_class, - target="_blank", - href=href, - ) - return html_div - - def _append_image(self, extra, extra_index, test_index): - image_base = '' - html_div = self._make_media_html_div( - extra, extra_index, test_index, image_base, "image" - ) - self.additional_html.append(html.div(html_div, class_="image")) - - def _append_video(self, extra, extra_index, test_index): - video_base = '' - html_div = self._make_media_html_div( - extra, extra_index, test_index, video_base, "video" - ) - self.additional_html.append(html.div(html_div, class_="video")) - - def _appendrow(self, outcome, report): - result = self.TestResult(outcome, report, self.logfile, self.config) - if result.row_table is not None: - index = bisect.bisect_right(self.results, result) - self.results.insert(index, result) - tbody = html.tbody( - result.row_table, - class_="{} results-table-row".format(result.outcome.lower()), - ) - if result.row_extra is not None: - tbody.append(result.row_extra) - self.test_logs.insert(index, tbody) - - def append_passed(self, report): - if report.when == "call": - if hasattr(report, "wasxfail"): - self.xpassed += 1 - self._appendrow("XPassed", report) - else: - self.passed += 1 - self._appendrow("Passed", report) - - def append_failed(self, report): - if getattr(report, "when", None) == "call": - if hasattr(report, "wasxfail"): - # pytest < 3.0 marked xpasses as failures - self.xpassed += 1 - self._appendrow("XPassed", report) - else: - self.failed += 1 - self._appendrow("Failed", report) - else: - self.errors += 1 - self._appendrow("Error", report) - - def append_rerun(self, report): - self.rerun += 1 - self._appendrow("Rerun", report) - - def append_skipped(self, report): - if hasattr(report, "wasxfail"): - self.xfailed += 1 - self._appendrow("XFailed", report) - else: - self.skipped += 1 - self._appendrow("Skipped", report) - - def _generate_report(self, session): - suite_stop_time = time.time() - suite_time_delta = suite_stop_time - self.suite_start_time - numtests = self.passed + self.failed + self.xpassed + self.xfailed - generated = datetime.datetime.now() - - with open( - os.path.join(os.path.dirname(__file__), "resources", "style.css") - ) as style_css_fp: - self.style_css = style_css_fp.read() - - if ansi_support(): - ansi_css = [ - "\n/******************************", - " * ANSI2HTML STYLES", - " ******************************/\n", - ] - ansi_css.extend([str(r) for r in ansi_support().style.get_styles()]) - self.style_css += "\n".join(ansi_css) - - # Add user-provided CSS - for path in self.config.getoption("css"): - self.style_css += "\n/******************************" - self.style_css += "\n * CUSTOM CSS" - self.style_css += f"\n * {path}" - self.style_css += "\n ******************************/\n\n" - with open(path) as f: - self.style_css += f.read() - - css_href = "assets/style.css" - html_css = html.link(href=css_href, rel="stylesheet", type="text/css") - if self.self_contained: - html_css = html.style(raw(self.style_css)) - - session.config.hook.pytest_html_report_title(report=self) - - head = html.head(html.meta(charset="utf-8"), html.title(self.title), html_css) - - class Outcome: - def __init__( - self, outcome, total=0, label=None, test_result=None, class_html=None - ): - self.outcome = outcome - self.label = label or outcome - self.class_html = class_html or outcome - self.total = total - self.test_result = test_result or outcome - - self.generate_checkbox() - self.generate_summary_item() - - def generate_checkbox(self): - checkbox_kwargs = {"data-test-result": self.test_result.lower()} - if self.total == 0: - checkbox_kwargs["disabled"] = "true" - - self.checkbox = html.input( - type="checkbox", - checked="true", - onChange="filterTable(this)", - name="filter_checkbox", - class_="filter", - hidden="true", - **checkbox_kwargs, - ) - - def generate_summary_item(self): - self.summary_item = html.span( - f"{self.total} {self.label}", class_=self.class_html - ) - - outcomes = [ - Outcome("passed", self.passed), - Outcome("skipped", self.skipped), - Outcome("failed", self.failed), - Outcome("error", self.errors, label="errors"), - Outcome("xfailed", self.xfailed, label="expected failures"), - Outcome("xpassed", self.xpassed, label="unexpected passes"), - ] - - if self.rerun is not None: - outcomes.append(Outcome("rerun", self.rerun)) - - summary = [ - html.p(f"{numtests} tests ran in {suite_time_delta:.2f} seconds. "), - html.p( - "(Un)check the boxes to filter the results.", - class_="filter", - hidden="true", - ), - ] - - for i, outcome in enumerate(outcomes, start=1): - summary.append(outcome.checkbox) - summary.append(outcome.summary_item) - if i < len(outcomes): - summary.append(", ") - - cells = [ - html.th("Result", class_="sortable result initial-sort", col="result"), - html.th("Test", class_="sortable", col="name"), - html.th("Duration", class_="sortable", col="duration"), - html.th("Links", class_="sortable links", col="links"), - ] - session.config.hook.pytest_html_results_table_header(cells=cells) - - results = [ - html.h2("Results"), - html.table( - [ - html.thead( - html.tr(cells), - html.tr( - [ - html.th( - "No results found. Try to check the filters", - colspan=len(cells), - ) - ], - id="not-found-message", - hidden="true", - ), - id="results-table-head", - ), - self.test_logs, - ], - id="results-table", - ), - ] - - with open( - os.path.join(os.path.dirname(__file__), "resources", "main.js") - ) as main_js_fp: - main_js = main_js_fp.read() - - body = html.body( - html.script(raw(main_js)), - html.h1(self.title), - html.p( - "Report generated on {} at {} by ".format( - generated.strftime("%d-%b-%Y"), generated.strftime("%H:%M:%S") - ), - html.a("pytest-html", href=__pypi_url__), - f" v{__version__}", - ), - onLoad="init()", - ) - - body.extend(self._generate_environment(session.config)) - - summary_prefix, summary_postfix = [], [] - session.config.hook.pytest_html_results_summary( - prefix=summary_prefix, summary=summary, postfix=summary_postfix - ) - body.extend([html.h2("Summary")] + summary_prefix + summary + summary_postfix) - - body.extend(results) - - doc = html.html(head, body) - - unicode_doc = "\n{}".format(doc.unicode(indent=2)) - - # Fix encoding issues, e.g. with surrogates - unicode_doc = unicode_doc.encode("utf-8", errors="xmlcharrefreplace") - return unicode_doc.decode("utf-8") - - def _generate_environment(self, config): - if not hasattr(config, "_metadata") or config._metadata is None: - return [] - - metadata = config._metadata - environment = [html.h2("Environment")] - rows = [] - - keys = [k for k in metadata.keys()] - if not isinstance(metadata, OrderedDict): - keys.sort() - - for key in keys: - value = metadata[key] - if isinstance(value, str) and value.startswith("http"): - value = html.a(value, href=value, target="_blank") - elif isinstance(value, (list, tuple, set)): - value = ", ".join(str(i) for i in sorted(map(str, value))) - elif isinstance(value, dict): - sorted_dict = {k: value[k] for k in sorted(value)} - value = json.dumps(sorted_dict) - raw_value_string = raw(str(value)) - rows.append(html.tr(html.td(key), html.td(raw_value_string))) - - environment.append(html.table(rows, id="environment")) - return environment - - def _save_report(self, report_content): - dir_name = os.path.dirname(self.logfile) - assets_dir = os.path.join(dir_name, "assets") - - os.makedirs(dir_name, exist_ok=True) - if not self.self_contained: - os.makedirs(assets_dir, exist_ok=True) - - with open(self.logfile, "w", encoding="utf-8") as f: - f.write(report_content) - if not self.self_contained: - style_path = os.path.join(assets_dir, "style.css") - with open(style_path, "w", encoding="utf-8") as f: - f.write(self.style_css) - - def _post_process_reports(self): - for test_name, test_reports in self.reports.items(): - outcome = "passed" - wasxfail = False - failure_when = None - full_text = "" - extras = [] - duration = 0.0 - - # in theory the last one should have all logs so we just go - # through them all to figure out the outcome, xfail, duration, - # extras, and when it swapped from pass - for test_report in test_reports: - if test_report.outcome == "rerun": - # reruns are separate test runs for all intensive purposes - self.append_rerun(test_report) - else: - full_text += test_report.longreprtext - extras.extend(getattr(test_report, "extra", [])) - duration += getattr(test_report, "duration", 0.0) - - if ( - test_report.outcome not in ("passed", "rerun") - and outcome == "passed" - ): - outcome = test_report.outcome - failure_when = test_report.when - - if hasattr(test_report, "wasxfail"): - wasxfail = True - - # the following test_report. = settings come at the end of us - # looping through all test_reports that make up a single - # case. - - # outcome on the right comes from the outcome of the various - # test_reports that make up this test case - # we are just carrying it over to the final report. - test_report.outcome = outcome - test_report.when = "call" - test_report.nodeid = test_name - test_report.longrepr = full_text - test_report.extra = extras - test_report.duration = duration - if wasxfail: - test_report.wasxfail = True - - if test_report.outcome == "passed": - self.append_passed(test_report) - elif test_report.outcome == "skipped": - self.append_skipped(test_report) - elif test_report.outcome == "failed": - test_report.when = failure_when - self.append_failed(test_report) - - def pytest_runtest_logreport(self, report): - self.reports[report.nodeid].append(report) - - def pytest_collectreport(self, report): - if report.failed: - self.append_failed(report) - - def pytest_sessionstart(self, session): - self.suite_start_time = time.time() - - def pytest_sessionfinish(self, session): - self._post_process_reports() - report_content = self._generate_report(session) - self._save_report(report_content) - - def pytest_terminal_summary(self, terminalreporter): - terminalreporter.write_sep("-", f"generated html file: file://{self.logfile}") diff --git a/src/pytest_html/result.py b/src/pytest_html/result.py new file mode 100644 index 0000000..f791e6d --- /dev/null +++ b/src/pytest_html/result.py @@ -0,0 +1,287 @@ +import json +import os +import re +import time +import warnings +from base64 import b64decode +from base64 import b64encode +from html import escape +from os.path import isfile + +from _pytest.logging import _remove_ansi_escape_sequences +from py.xml import html +from py.xml import raw + +from . import extras +from .util import ansi_support + + +class TestResult: + def __init__(self, outcome, report, logfile, config): + self.test_id = report.nodeid.encode("utf-8").decode("unicode_escape") + if getattr(report, "when", "call") != "call": + self.test_id = "::".join([report.nodeid, report.when]) + self.time = getattr(report, "duration", 0.0) + self.formatted_time = self._format_time(report) + self.outcome = outcome + self.additional_html = [] + self.links_html = [] + self.self_contained = config.getoption("self_contained_html") + self.max_asset_filename_length = int(config.getini("max_asset_filename_length")) + self.logfile = logfile + self.config = config + self.row_table = self.row_extra = None + + test_index = hasattr(report, "rerun") and report.rerun + 1 or 0 + + for extra_index, extra in enumerate(getattr(report, "extra", [])): + self.append_extra_html(extra, extra_index, test_index) + + self.append_log_html( + report, + self.additional_html, + config.option.capture, + config.option.showcapture, + ) + + cells = [ + html.td(self.outcome, class_="col-result"), + html.td(self.test_id, class_="col-name"), + html.td(self.formatted_time, class_="col-duration"), + html.td(self.links_html, class_="col-links"), + ] + + self.config.hook.pytest_html_results_table_row(report=report, cells=cells) + + self.config.hook.pytest_html_results_table_html( + report=report, data=self.additional_html + ) + + if len(cells) > 0: + tr_class = None + if self.config.getini("render_collapsed"): + tr_class = "collapsed" + self.row_table = html.tr(cells) + self.row_extra = html.tr( + html.td(self.additional_html, class_="extra", colspan=len(cells)), + class_=tr_class, + ) + + def __lt__(self, other): + order = ( + "Error", + "Failed", + "Rerun", + "XFailed", + "XPassed", + "Skipped", + "Passed", + ) + return order.index(self.outcome) < order.index(other.outcome) + + def create_asset(self, content, extra_index, test_index, file_extension, mode="w"): + asset_file_name = "{}_{}_{}.{}".format( + re.sub(r"[^\w\.]", "_", self.test_id), + str(extra_index), + str(test_index), + file_extension, + )[-self.max_asset_filename_length :] + asset_path = os.path.join( + os.path.dirname(self.logfile), "assets", asset_file_name + ) + + os.makedirs(os.path.dirname(asset_path), exist_ok=True) + + relative_path = f"assets/{asset_file_name}" + + kwargs = {"encoding": "utf-8"} if "b" not in mode else {} + with open(asset_path, mode, **kwargs) as f: + f.write(content) + return relative_path + + def append_extra_html(self, extra, extra_index, test_index): + href = None + if extra.get("format_type") == extras.FORMAT_IMAGE: + self._append_image(extra, extra_index, test_index) + + elif extra.get("format_type") == extras.FORMAT_HTML: + self.additional_html.append(html.div(raw(extra.get("content")))) + + elif extra.get("format_type") == extras.FORMAT_JSON: + content = json.dumps(extra.get("content")) + if self.self_contained: + href = self._data_uri(content, mime_type=extra.get("mime_type")) + else: + href = self.create_asset( + content, extra_index, test_index, extra.get("extension") + ) + + elif extra.get("format_type") == extras.FORMAT_TEXT: + content = extra.get("content") + if isinstance(content, bytes): + content = content.decode("utf-8") + if self.self_contained: + href = self._data_uri(content) + else: + href = self.create_asset( + content, extra_index, test_index, extra.get("extension") + ) + + elif extra.get("format_type") == extras.FORMAT_URL: + href = extra.get("content") + + elif extra.get("format_type") == extras.FORMAT_VIDEO: + self._append_video(extra, extra_index, test_index) + + if href is not None: + self.links_html.append( + html.a( + extra.get("name"), + class_=extra.get("format_type"), + href=href, + target="_blank", + ) + ) + self.links_html.append(" ") + + def _format_time(self, report): + # parse the report duration into its display version and return + # it to the caller + duration = getattr(report, "duration", None) + if duration is None: + return "" + + duration_formatter = getattr(report, "duration_formatter", None) + string_duration = str(duration) + if duration_formatter is None: + if "." in string_duration: + split_duration = string_duration.split(".") + split_duration[1] = split_duration[1][0:2] + + string_duration = ".".join(split_duration) + + return string_duration + else: + # support %f, since time.strftime doesn't support it out of the box + # keep a precision of 2 for legacy reasons + formatted_milliseconds = "00" + if "." in string_duration: + milliseconds = string_duration.split(".")[1] + formatted_milliseconds = milliseconds[0:2] + + duration_formatter = duration_formatter.replace( + "%f", formatted_milliseconds + ) + duration_as_gmtime = time.gmtime(report.duration) + return time.strftime(duration_formatter, duration_as_gmtime) + + def _populate_html_log_div(self, log, report): + if report.longrepr: + # longreprtext is only filled out on failure by pytest + # otherwise will be None. + # Use full_text if longreprtext is None-ish + # we added full_text elsewhere in this file. + text = report.longreprtext or report.full_text + for line in text.splitlines(): + separator = line.startswith("_ " * 10) + if separator: + log.append(line[:80]) + else: + exception = line.startswith("E ") + if exception: + log.append(html.span(raw(escape(line)), class_="error")) + else: + log.append(raw(escape(line))) + log.append(html.br()) + + for section in report.sections: + header, content = map(escape, section) + log.append(f" {header:-^80} ") + log.append(html.br()) + + if ansi_support(): + converter = ansi_support().Ansi2HTMLConverter( + inline=False, escaped=False + ) + content = converter.convert(content, full=False) + else: + content = _remove_ansi_escape_sequences(content) + + log.append(raw(content)) + log.append(html.br()) + + def append_log_html( + self, + report, + additional_html, + pytest_capture_value, + pytest_show_capture_value, + ): + log = html.div(class_="log") + + should_skip_captured_output = pytest_capture_value == "no" + if report.outcome == "failed" and not should_skip_captured_output: + should_skip_captured_output = pytest_show_capture_value == "no" + if not should_skip_captured_output: + self._populate_html_log_div(log, report) + + if len(log) == 0: + log = html.div(class_="empty log") + log.append("No log output captured.") + + additional_html.append(log) + + def _make_media_html_div( + self, extra, extra_index, test_index, base_extra_string, base_extra_class + ): + content = extra.get("content") + try: + is_uri_or_path = content.startswith(("file", "http")) or isfile(content) + except ValueError: + # On Windows, os.path.isfile throws this exception when + # passed a b64 encoded image. + is_uri_or_path = False + if is_uri_or_path: + if self.self_contained: + warnings.warn( + "Self-contained HTML report " + "includes link to external " + f"resource: {content}" + ) + + html_div = html.a( + raw(base_extra_string.format(extra.get("content"))), href=content + ) + elif self.self_contained: + src = f"data:{extra.get('mime_type')};base64,{content}" + html_div = raw(base_extra_string.format(src)) + else: + content = b64decode(content.encode("utf-8")) + href = src = self.create_asset( + content, extra_index, test_index, extra.get("extension"), "wb" + ) + html_div = html.a( + raw(base_extra_string.format(src)), + class_=base_extra_class, + target="_blank", + href=href, + ) + return html_div + + def _append_image(self, extra, extra_index, test_index): + image_base = '' + html_div = self._make_media_html_div( + extra, extra_index, test_index, image_base, "image" + ) + self.additional_html.append(html.div(html_div, class_="image")) + + def _append_video(self, extra, extra_index, test_index): + video_base = '' + html_div = self._make_media_html_div( + extra, extra_index, test_index, video_base, "video" + ) + self.additional_html.append(html.div(html_div, class_="video")) + + def _data_uri(self, content, mime_type="text/plain", charset="utf-8"): + data = b64encode(content.encode(charset)).decode("ascii") + return f"data:{mime_type};charset={charset};base64,{data}" diff --git a/src/pytest_html/util.py b/src/pytest_html/util.py new file mode 100644 index 0000000..37259ec --- /dev/null +++ b/src/pytest_html/util.py @@ -0,0 +1,12 @@ +import importlib +from functools import lru_cache + + +@lru_cache() +def ansi_support(): + try: + # from ansi2html import Ansi2HTMLConverter, style # NOQA + return importlib.import_module("ansi2html") + except ImportError: + # ansi2html is not installed + pass diff --git a/testing/test_pytest_html.py b/testing/test_pytest_html.py index 3e0c59b..b2d23af 100644 --- a/testing/test_pytest_html.py +++ b/testing/test_pytest_html.py @@ -529,7 +529,7 @@ class TestHTML: assert f'' in html def test_extra_image_windows(self, mocker, testdir): - mock_isfile = mocker.patch("pytest_html.plugin.isfile") + mock_isfile = mocker.patch("pytest_html.result.isfile") mock_isfile.side_effect = ValueError("stat: path too long for Windows") self.test_extra_image(testdir, "image/png", "png") assert mock_isfile.call_count == 1 @@ -558,7 +558,7 @@ class TestHTML: ) def test_extra_video_windows(self, mocker, testdir): - mock_isfile = mocker.patch("pytest_html.plugin.isfile") + mock_isfile = mocker.patch("pytest_html.result.isfile") mock_isfile.side_effect = ValueError("stat: path too long for Windows") self.test_extra_video(testdir, "video/mp4", "mp4") assert mock_isfile.call_count == 1 @@ -923,7 +923,8 @@ class TestHTML: ) def test_ansi_color(self, testdir, mocker, with_ansi): if not with_ansi: - mock_ansi_support = mocker.patch("pytest_html.plugin.ansi_support") + mock_ansi_support = mocker.patch("pytest_html.html_report.ansi_support") + mock_ansi_support = mocker.patch("pytest_html.result.ansi_support") mock_ansi_support.return_value = None pass_content = [