diff --git a/src/pytest_html/basereport.py b/src/pytest_html/basereport.py index f9aba421..89817d1b 100644 --- a/src/pytest_html/basereport.py +++ b/src/pytest_html/basereport.py @@ -3,8 +3,6 @@ import os import re import warnings -from collections import defaultdict -from functools import partial from pathlib import Path import pytest @@ -17,99 +15,12 @@ from pytest_html.table import Header from pytest_html.table import Html from pytest_html.table import Row +from pytest_html.util import _ansi_styles from pytest_html.util import cleanup_unserializable -try: - from ansi2html import Ansi2HTMLConverter, style - - converter = Ansi2HTMLConverter(inline=False, escaped=False) - _handle_ansi = partial(converter.convert, full=False) - _ansi_styles = style.get_styles() -except ImportError: - from _pytest.logging import _remove_ansi_escape_sequences - - _handle_ansi = _remove_ansi_escape_sequences - _ansi_styles = [] - class BaseReport: - class ReportData: - def __init__(self, title, config): - self._config = config - self._data = { - "title": title, - "collectedItems": 0, - "runningState": "not_started", - "environment": {}, - "tests": defaultdict(list), - "resultsTableHeader": {}, - "additionalSummary": defaultdict(list), - } - - collapsed = config.getini("render_collapsed") - if collapsed: - if collapsed.lower() == "true": - warnings.warn( - "'render_collapsed = True' is deprecated and support " - "will be removed in the next major release. " - "Please use 'render_collapsed = all' instead.", - DeprecationWarning, - ) - self.set_data( - "collapsed", [outcome.lower() for outcome in collapsed.split(",")] - ) - - @property - def title(self): - return self._data["title"] - - @title.setter - def title(self, title): - self._data["title"] = title - - @property - def config(self): - return self._config - - @property - def data(self): - return self._data - - def set_data(self, key, value): - self._data[key] = value - - def add_test(self, test_data, report, row, remove_log=False): - for sortable, value in row.sortables.items(): - test_data[sortable] = value - - # regardless of pass or fail we must add teardown logging to "call" - if report.when == "teardown" and not remove_log: - self.update_test_log(report) - - # passed "setup" and "teardown" are not added to the html - if report.when == "call" or ( - report.when in ["setup", "teardown"] and report.outcome != "passed" - ): - if not remove_log: - processed_logs = _process_logs(report) - test_data["log"] = _handle_ansi(processed_logs) - self._data["tests"][report.nodeid].append(test_data) - return True - - return False - - def update_test_log(self, report): - log = [] - for test in self._data["tests"][report.nodeid]: - if test["testId"] == report.nodeid and "log" in test: - for section in report.sections: - header, content = section - if "teardown" in header: - log.append(f"{' ' + header + ' ':-^80}") - log.append(content) - test["log"] += _handle_ansi("\n".join(log)) - - def __init__(self, report_path, config, default_css="style.css"): + def __init__(self, report_path, config, report_data, default_css="style.css"): self._report_path = Path(os.path.expandvars(report_path)).expanduser() self._report_path.parent.mkdir(parents=True, exist_ok=True) self._resources_path = Path(__file__).parent.joinpath("resources") @@ -122,7 +33,8 @@ def __init__(self, report_path, config, default_css="style.css"): config.getini("max_asset_filename_length") ) - self._report = self.ReportData(self._report_path.name, config) + self._report = report_data + self._report.title = self._report_path.name @property def css(self): @@ -336,25 +248,6 @@ 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("<", "<").replace(">", ">") + "\n") - for section in report.sections: - header, content = section - log.append(f"{' ' + header + ' ':-^80}") - log.append(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 "\n".join(log) - - def _process_outcome(report): if _is_error(report): return "Error" diff --git a/src/pytest_html/plugin.py b/src/pytest_html/plugin.py index a8d0778c..fd43055b 100644 --- a/src/pytest_html/plugin.py +++ b/src/pytest_html/plugin.py @@ -8,6 +8,7 @@ from pytest_html.basereport import BaseReport as HTMLReport # noqa: F401 from pytest_html.report import Report +from pytest_html.report_data import ReportData from pytest_html.selfcontained_report import SelfContainedReport @@ -80,10 +81,11 @@ def pytest_configure(config): if not hasattr(config, "workerinput"): # prevent opening html_path on worker nodes (xdist) + report_data = ReportData(config) if config.getoption("self_contained_html"): - html = SelfContainedReport(html_path, config) + html = SelfContainedReport(html_path, config, report_data) else: - html = Report(html_path, config) + html = Report(html_path, config, report_data) config.pluginmanager.register(html) diff --git a/src/pytest_html/report.py b/src/pytest_html/report.py index 3ed35bfb..ed1cd9ed 100644 --- a/src/pytest_html/report.py +++ b/src/pytest_html/report.py @@ -6,8 +6,8 @@ class Report(BaseReport): - def __init__(self, report_path, config): - super().__init__(report_path, config) + def __init__(self, report_path, config, report_data): + super().__init__(report_path, config, report_data) self._assets_path = Path(self._report_path.parent, "assets") self._assets_path.mkdir(parents=True, exist_ok=True) self._css_path = Path(self._assets_path, "style.css") diff --git a/src/pytest_html/report_data.py b/src/pytest_html/report_data.py new file mode 100644 index 00000000..1a966ed5 --- /dev/null +++ b/src/pytest_html/report_data.py @@ -0,0 +1,100 @@ +import warnings +from collections import defaultdict + +from pytest_html.util import _handle_ansi + + +class ReportData: + def __init__(self, config): + self._config = config + self._data = { + "title": "", + "collectedItems": 0, + "runningState": "not_started", + "environment": {}, + "tests": defaultdict(list), + "resultsTableHeader": {}, + "additionalSummary": defaultdict(list), + } + + collapsed = config.getini("render_collapsed") + if collapsed: + if collapsed.lower() == "true": + warnings.warn( + "'render_collapsed = True' is deprecated and support " + "will be removed in the next major release. " + "Please use 'render_collapsed = all' instead.", + DeprecationWarning, + ) + self.set_data( + "collapsed", [outcome.lower() for outcome in collapsed.split(",")] + ) + + @property + def title(self): + return self._data["title"] + + @title.setter + def title(self, title): + self._data["title"] = title + + @property + def config(self): + return self._config + + @property + def data(self): + return self._data + + def set_data(self, key, value): + self._data[key] = value + + def add_test(self, test_data, report, row, remove_log=False): + for sortable, value in row.sortables.items(): + test_data[sortable] = value + + # regardless of pass or fail we must add teardown logging to "call" + if report.when == "teardown" and not remove_log: + self.update_test_log(report) + + # passed "setup" and "teardown" are not added to the html + if report.when == "call" or ( + report.when in ["setup", "teardown"] and report.outcome != "passed" + ): + if not remove_log: + processed_logs = _process_logs(report) + test_data["log"] = _handle_ansi(processed_logs) + self._data["tests"][report.nodeid].append(test_data) + return True + + return False + + def update_test_log(self, report): + log = [] + for test in self._data["tests"][report.nodeid]: + if test["testId"] == report.nodeid and "log" in test: + for section in report.sections: + header, content = section + if "teardown" in header: + log.append(f"{' ' + header + ' ':-^80}") + log.append(content) + test["log"] += _handle_ansi("\n".join(log)) + + +def _process_logs(report): + log = [] + if report.longreprtext: + log.append(report.longreprtext.replace("<", "<").replace(">", ">") + "\n") + for section in report.sections: + header, content = section + log.append(f"{' ' + header + ' ':-^80}") + log.append(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 "\n".join(log) diff --git a/src/pytest_html/selfcontained_report.py b/src/pytest_html/selfcontained_report.py index 55fb8556..c5fd1e6b 100644 --- a/src/pytest_html/selfcontained_report.py +++ b/src/pytest_html/selfcontained_report.py @@ -6,8 +6,8 @@ class SelfContainedReport(BaseReport): - def __init__(self, report_path, config): - super().__init__(report_path, config) + def __init__(self, report_path, config, report_data): + super().__init__(report_path, config, report_data) @property def css(self): diff --git a/src/pytest_html/util.py b/src/pytest_html/util.py index 3cfce7a2..3d1dea0e 100644 --- a/src/pytest_html/util.py +++ b/src/pytest_html/util.py @@ -1,18 +1,20 @@ -import importlib import json -from functools import lru_cache +from functools import partial from typing import Any from typing import Dict -@lru_cache() -def ansi_support(): - try: - # from ansi2html import Ansi2HTMLConverter, style # NOQA - return importlib.import_module("ansi2html") - except ImportError: - # ansi2html is not installed - pass +try: + from ansi2html import Ansi2HTMLConverter, style + + converter = Ansi2HTMLConverter(inline=False, escaped=False) + _handle_ansi = partial(converter.convert, full=False) + _ansi_styles = style.get_styles() +except ImportError: + from _pytest.logging import _remove_ansi_escape_sequences + + _handle_ansi = _remove_ansi_escape_sequences + _ansi_styles = [] def cleanup_unserializable(d: Dict[str, Any]) -> Dict[str, Any]: