pytest-html/src/pytest_html/basereport.py

303 lines
10 KiB
Python

# 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 datetime
import json
import math
import os
import re
import warnings
from pathlib import Path
import pytest
from pytest_metadata.plugin import metadata_key
from pytest_html import __version__
from pytest_html import extras
from pytest_html.util import cleanup_unserializable
class BaseReport:
def __init__(self, report_path, config, report_data, template, css):
self._report_path = Path(os.path.expandvars(report_path)).expanduser()
self._report_path.parent.mkdir(parents=True, exist_ok=True)
self._config = config
self._template = template
self._css = css
self._max_asset_filename_length = int(
config.getini("max_asset_filename_length")
)
self._report = report_data
self._report.title = self._report_path.name
@property
def css(self):
# implement in subclasses
return
def _asset_filename(self, test_id, extra_index, test_index, file_extension):
return "{}_{}_{}.{}".format(
re.sub(r"[^\w.]", "_", test_id),
str(extra_index),
str(test_index),
file_extension,
)[-self._max_asset_filename_length :]
def _generate_report(self, self_contained=False):
generated = datetime.datetime.now()
test_data = cleanup_unserializable(self._report.data)
test_data = json.dumps(test_data)
rendered_report = self._template.render(
title=self._report.title,
date=generated.strftime("%d-%b-%Y"),
time=generated.strftime("%H:%M:%S"),
version=__version__,
styles=self.css,
run_count=self._run_count(),
self_contained=self_contained,
outcomes=self._report.data["outcomes"],
test_data=test_data,
table_head=self._report.data["resultsTableHeader"],
prefix=self._report.data["additionalSummary"]["prefix"],
summary=self._report.data["additionalSummary"]["summary"],
postfix=self._report.data["additionalSummary"]["postfix"],
)
self._write_report(rendered_report)
def _generate_environment(self, metadata_key):
metadata = self._config.stash[metadata_key]
for key in metadata.keys():
value = metadata[key]
if self._is_redactable_environment_variable(key):
black_box_ascii_value = 0x2593
metadata[key] = "".join(chr(black_box_ascii_value) for _ in str(value))
return metadata
def _is_redactable_environment_variable(self, environment_variable):
redactable_regexes = self._config.getini("environment_table_redact_list")
for redactable_regex in redactable_regexes:
if re.match(redactable_regex, environment_variable):
return True
return False
def _data_content(self, *args, **kwargs):
pass
def _media_content(self, *args, **kwargs):
pass
def _process_extras(self, report, test_id):
test_index = hasattr(report, "rerun") and report.rerun + 1 or 0
report_extras = getattr(report, "extras", [])
for extra_index, extra in enumerate(report_extras):
content = extra["content"]
asset_name = self._asset_filename(
test_id.encode("utf-8").decode("unicode_escape"),
extra_index,
test_index,
extra["extension"],
)
if extra["format_type"] == extras.FORMAT_JSON:
content = json.dumps(content)
extra["content"] = self._data_content(
content, asset_name=asset_name, mime_type=extra["mime_type"]
)
if extra["format_type"] == extras.FORMAT_TEXT:
if isinstance(content, bytes):
content = content.decode("utf-8")
extra["content"] = self._data_content(
content, asset_name=asset_name, mime_type=extra["mime_type"]
)
if extra["format_type"] in [extras.FORMAT_IMAGE, extras.FORMAT_VIDEO]:
extra["content"] = self._media_content(
content, asset_name=asset_name, mime_type=extra["mime_type"]
)
return report_extras
def _write_report(self, rendered_report):
with self._report_path.open("w", encoding="utf-8") as f:
f.write(rendered_report)
def _run_count(self):
data = self._report.data
relevant_outcomes = ["passed", "failed", "xpassed", "xfailed"]
counts = 0
for outcome in data["outcomes"].keys():
if outcome in relevant_outcomes:
counts += data["outcomes"][outcome]["value"]
plural = counts > 1
duration = _format_duration(data["totalDuration"])
if data["runningState"].lower() == "finished":
return f"{counts} {'tests' if plural else 'test'} took {duration}."
return (
f"{counts}/{data['collectedItems']} {'tests' if plural else 'test'} done."
)
@pytest.hookimpl(trylast=True)
def pytest_sessionstart(self, session):
self._report.set_data("environment", self._generate_environment(metadata_key))
session.config.hook.pytest_html_report_title(report=self._report)
headers = self._report.data["resultsTableHeader"]
session.config.hook.pytest_html_results_table_header(cells=headers)
self._report.data["resultsTableHeader"] = _fix_py(headers)
self._report.set_data("runningState", "Started")
self._generate_report()
@pytest.hookimpl(trylast=True)
def pytest_sessionfinish(self, session):
session.config.hook.pytest_html_results_summary(
prefix=self._report.data["additionalSummary"]["prefix"],
summary=self._report.data["additionalSummary"]["summary"],
postfix=self._report.data["additionalSummary"]["postfix"],
session=session,
)
self._report.set_data("runningState", "Finished")
self._generate_report()
@pytest.hookimpl(trylast=True)
def pytest_terminal_summary(self, terminalreporter):
terminalreporter.write_sep(
"-",
f"Generated html report: file://{self._report_path.resolve().as_posix()}",
)
@pytest.hookimpl(trylast=True)
def pytest_collection_finish(self, session):
self._report.set_data("collectedItems", len(session.items))
@pytest.hookimpl(trylast=True)
def pytest_runtest_logreport(self, report):
if hasattr(report, "duration_formatter"):
warnings.warn(
"'duration_formatter' has been removed and no longer has any effect!",
DeprecationWarning,
)
outcome = _process_outcome(report)
data = {
"result": outcome,
"duration": _format_duration(report.duration),
}
self._report.data["totalDuration"] += report.duration
test_id = report.nodeid
if report.when != "call":
test_id += f"::{report.when}"
data["testId"] = test_id
data["extras"] = self._process_extras(report, test_id)
links = [
extra
for extra in data["extras"]
if extra["format_type"] in ["json", "text", "url"]
]
cells = [
f'<td class="col-result">{data["result"]}</td>',
f'<td class="col-name">{data["testId"]}</td>',
f'<td class="col-duration">{data["duration"]}</td>',
f'<td class="col-links">{_process_links(links)}</td>',
]
self._config.hook.pytest_html_results_table_row(report=report, cells=cells)
if not cells:
return
cells = _fix_py(cells)
data["resultsTableRow"] = cells
# don't count passed setups and teardowns
if not (report.when in ["setup", "teardown"] and report.outcome == "passed"):
self._report.data["outcomes"][outcome.lower()]["value"] += 1
processed_logs = _process_logs(report)
self._config.hook.pytest_html_results_table_html(
report=report, data=processed_logs
)
if self._report.add_test(data, report, processed_logs):
self._generate_report()
def _format_duration(duration):
if duration < 1:
return "{} ms".format(round(duration * 1000))
hours = math.floor(duration / 3600)
remaining_seconds = duration % 3600
minutes = math.floor(remaining_seconds / 60)
remaining_seconds = remaining_seconds % 60
seconds = round(remaining_seconds)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
def _is_error(report):
return report.when in ["setup", "teardown"] and report.outcome == "failed"
def _process_logs(report):
log = []
if report.longreprtext:
log.append(report.longreprtext.replace("<", "&lt;").replace(">", "&gt;") + "\n")
# Don't add captured output to reruns
if report.outcome != "rerun":
for section in report.sections:
header, content = section
log.append(f"{' ' + header + ' ':-^80}\n{content}")
# weird formatting related to logs
if "log" in header:
log.append("")
if "call" in header:
log.append("")
if not log:
log.append("No log output captured.")
return log
def _process_outcome(report):
if _is_error(report):
return "Error"
if hasattr(report, "wasxfail"):
if report.outcome in ["passed", "failed"]:
return "XPassed"
if report.outcome == "skipped":
return "XFailed"
return report.outcome.capitalize()
def _process_links(links):
a_tag = '<a target="_blank" href="{content}" class="col-links__extra {format_type}">{name}</a>'
return "".join([a_tag.format_map(link) for link in links])
def _fix_py(cells):
# backwards-compat
new_cells = []
for html in cells:
if not isinstance(html, str):
if html.__module__.startswith("py."):
warnings.warn(
"The 'py' module is deprecated and support "
"will be removed in a future release.",
DeprecationWarning,
)
html = str(html)
html = html.replace("col=", "data-column-type=")
new_cells.append(html)
return new_cells