Skip to content

Commit c9f442b

Browse files
authored
split plugin.py into smaller files (#427)
1 parent 08446c9 commit c9f442b

File tree

6 files changed

+667
-654
lines changed

6 files changed

+667
-654
lines changed

src/pytest_html/html_report.py

Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
import bisect
2+
import datetime
3+
import json
4+
import os
5+
import time
6+
from collections import defaultdict
7+
from collections import OrderedDict
8+
9+
from py.xml import html
10+
from py.xml import raw
11+
12+
from . import __pypi_url__
13+
from . import __version__
14+
from .outcome import Outcome
15+
from .result import TestResult
16+
from .util import ansi_support
17+
18+
19+
class HTMLReport:
20+
def __init__(self, logfile, config):
21+
logfile = os.path.expanduser(os.path.expandvars(logfile))
22+
self.logfile = os.path.abspath(logfile)
23+
self.test_logs = []
24+
self.title = os.path.basename(self.logfile)
25+
self.results = []
26+
self.errors = self.failed = 0
27+
self.passed = self.skipped = 0
28+
self.xfailed = self.xpassed = 0
29+
has_rerun = config.pluginmanager.hasplugin("rerunfailures")
30+
self.rerun = 0 if has_rerun else None
31+
self.self_contained = config.getoption("self_contained_html")
32+
self.config = config
33+
self.reports = defaultdict(list)
34+
35+
def _appendrow(self, outcome, report):
36+
result = TestResult(outcome, report, self.logfile, self.config)
37+
if result.row_table is not None:
38+
index = bisect.bisect_right(self.results, result)
39+
self.results.insert(index, result)
40+
tbody = html.tbody(
41+
result.row_table,
42+
class_="{} results-table-row".format(result.outcome.lower()),
43+
)
44+
if result.row_extra is not None:
45+
tbody.append(result.row_extra)
46+
self.test_logs.insert(index, tbody)
47+
48+
def append_passed(self, report):
49+
if report.when == "call":
50+
if hasattr(report, "wasxfail"):
51+
self.xpassed += 1
52+
self._appendrow("XPassed", report)
53+
else:
54+
self.passed += 1
55+
self._appendrow("Passed", report)
56+
57+
def append_failed(self, report):
58+
if getattr(report, "when", None) == "call":
59+
if hasattr(report, "wasxfail"):
60+
# pytest < 3.0 marked xpasses as failures
61+
self.xpassed += 1
62+
self._appendrow("XPassed", report)
63+
else:
64+
self.failed += 1
65+
self._appendrow("Failed", report)
66+
else:
67+
self.errors += 1
68+
self._appendrow("Error", report)
69+
70+
def append_rerun(self, report):
71+
self.rerun += 1
72+
self._appendrow("Rerun", report)
73+
74+
def append_skipped(self, report):
75+
if hasattr(report, "wasxfail"):
76+
self.xfailed += 1
77+
self._appendrow("XFailed", report)
78+
else:
79+
self.skipped += 1
80+
self._appendrow("Skipped", report)
81+
82+
def _generate_report(self, session):
83+
suite_stop_time = time.time()
84+
suite_time_delta = suite_stop_time - self.suite_start_time
85+
numtests = self.passed + self.failed + self.xpassed + self.xfailed
86+
generated = datetime.datetime.now()
87+
88+
with open(
89+
os.path.join(os.path.dirname(__file__), "resources", "style.css")
90+
) as style_css_fp:
91+
self.style_css = style_css_fp.read()
92+
93+
if ansi_support():
94+
ansi_css = [
95+
"\n/******************************",
96+
" * ANSI2HTML STYLES",
97+
" ******************************/\n",
98+
]
99+
ansi_css.extend([str(r) for r in ansi_support().style.get_styles()])
100+
self.style_css += "\n".join(ansi_css)
101+
102+
# <DF> Add user-provided CSS
103+
for path in self.config.getoption("css"):
104+
self.style_css += "\n/******************************"
105+
self.style_css += "\n * CUSTOM CSS"
106+
self.style_css += f"\n * {path}"
107+
self.style_css += "\n ******************************/\n\n"
108+
with open(path) as f:
109+
self.style_css += f.read()
110+
111+
css_href = "assets/style.css"
112+
html_css = html.link(href=css_href, rel="stylesheet", type="text/css")
113+
if self.self_contained:
114+
html_css = html.style(raw(self.style_css))
115+
116+
session.config.hook.pytest_html_report_title(report=self)
117+
118+
head = html.head(html.meta(charset="utf-8"), html.title(self.title), html_css)
119+
120+
outcomes = [
121+
Outcome("passed", self.passed),
122+
Outcome("skipped", self.skipped),
123+
Outcome("failed", self.failed),
124+
Outcome("error", self.errors, label="errors"),
125+
Outcome("xfailed", self.xfailed, label="expected failures"),
126+
Outcome("xpassed", self.xpassed, label="unexpected passes"),
127+
]
128+
129+
if self.rerun is not None:
130+
outcomes.append(Outcome("rerun", self.rerun))
131+
132+
summary = [
133+
html.p(f"{numtests} tests ran in {suite_time_delta:.2f} seconds. "),
134+
html.p(
135+
"(Un)check the boxes to filter the results.",
136+
class_="filter",
137+
hidden="true",
138+
),
139+
]
140+
141+
for i, outcome in enumerate(outcomes, start=1):
142+
summary.append(outcome.checkbox)
143+
summary.append(outcome.summary_item)
144+
if i < len(outcomes):
145+
summary.append(", ")
146+
147+
cells = [
148+
html.th("Result", class_="sortable result initial-sort", col="result"),
149+
html.th("Test", class_="sortable", col="name"),
150+
html.th("Duration", class_="sortable", col="duration"),
151+
html.th("Links", class_="sortable links", col="links"),
152+
]
153+
session.config.hook.pytest_html_results_table_header(cells=cells)
154+
155+
results = [
156+
html.h2("Results"),
157+
html.table(
158+
[
159+
html.thead(
160+
html.tr(cells),
161+
html.tr(
162+
[
163+
html.th(
164+
"No results found. Try to check the filters",
165+
colspan=len(cells),
166+
)
167+
],
168+
id="not-found-message",
169+
hidden="true",
170+
),
171+
id="results-table-head",
172+
),
173+
self.test_logs,
174+
],
175+
id="results-table",
176+
),
177+
]
178+
179+
with open(
180+
os.path.join(os.path.dirname(__file__), "resources", "main.js")
181+
) as main_js_fp:
182+
main_js = main_js_fp.read()
183+
184+
body = html.body(
185+
html.script(raw(main_js)),
186+
html.h1(self.title),
187+
html.p(
188+
"Report generated on {} at {} by ".format(
189+
generated.strftime("%d-%b-%Y"), generated.strftime("%H:%M:%S")
190+
),
191+
html.a("pytest-html", href=__pypi_url__),
192+
f" v{__version__}",
193+
),
194+
onLoad="init()",
195+
)
196+
197+
body.extend(self._generate_environment(session.config))
198+
199+
summary_prefix, summary_postfix = [], []
200+
session.config.hook.pytest_html_results_summary(
201+
prefix=summary_prefix, summary=summary, postfix=summary_postfix
202+
)
203+
body.extend([html.h2("Summary")] + summary_prefix + summary + summary_postfix)
204+
205+
body.extend(results)
206+
207+
doc = html.html(head, body)
208+
209+
unicode_doc = "<!DOCTYPE html>\n{}".format(doc.unicode(indent=2))
210+
211+
# Fix encoding issues, e.g. with surrogates
212+
unicode_doc = unicode_doc.encode("utf-8", errors="xmlcharrefreplace")
213+
return unicode_doc.decode("utf-8")
214+
215+
def _generate_environment(self, config):
216+
if not hasattr(config, "_metadata") or config._metadata is None:
217+
return []
218+
219+
metadata = config._metadata
220+
environment = [html.h2("Environment")]
221+
rows = []
222+
223+
keys = [k for k in metadata.keys()]
224+
if not isinstance(metadata, OrderedDict):
225+
keys.sort()
226+
227+
for key in keys:
228+
value = metadata[key]
229+
if isinstance(value, str) and value.startswith("http"):
230+
value = html.a(value, href=value, target="_blank")
231+
elif isinstance(value, (list, tuple, set)):
232+
value = ", ".join(str(i) for i in sorted(map(str, value)))
233+
elif isinstance(value, dict):
234+
sorted_dict = {k: value[k] for k in sorted(value)}
235+
value = json.dumps(sorted_dict)
236+
raw_value_string = raw(str(value))
237+
rows.append(html.tr(html.td(key), html.td(raw_value_string)))
238+
239+
environment.append(html.table(rows, id="environment"))
240+
return environment
241+
242+
def _save_report(self, report_content):
243+
dir_name = os.path.dirname(self.logfile)
244+
assets_dir = os.path.join(dir_name, "assets")
245+
246+
os.makedirs(dir_name, exist_ok=True)
247+
if not self.self_contained:
248+
os.makedirs(assets_dir, exist_ok=True)
249+
250+
with open(self.logfile, "w", encoding="utf-8") as f:
251+
f.write(report_content)
252+
if not self.self_contained:
253+
style_path = os.path.join(assets_dir, "style.css")
254+
with open(style_path, "w", encoding="utf-8") as f:
255+
f.write(self.style_css)
256+
257+
def _post_process_reports(self):
258+
for test_name, test_reports in self.reports.items():
259+
report_outcome = "passed"
260+
wasxfail = False
261+
failure_when = None
262+
full_text = ""
263+
extras = []
264+
duration = 0.0
265+
266+
# in theory the last one should have all logs so we just go
267+
# through them all to figure out the outcome, xfail, duration,
268+
# extras, and when it swapped from pass
269+
for test_report in test_reports:
270+
if test_report.outcome == "rerun":
271+
# reruns are separate test runs for all intensive purposes
272+
self.append_rerun(test_report)
273+
else:
274+
full_text += test_report.longreprtext
275+
extras.extend(getattr(test_report, "extra", []))
276+
duration += getattr(test_report, "duration", 0.0)
277+
278+
if (
279+
test_report.outcome not in ("passed", "rerun")
280+
and report_outcome == "passed"
281+
):
282+
report_outcome = test_report.outcome
283+
failure_when = test_report.when
284+
285+
if hasattr(test_report, "wasxfail"):
286+
wasxfail = True
287+
288+
# the following test_report.<X> = settings come at the end of us
289+
# looping through all test_reports that make up a single
290+
# case.
291+
292+
# outcome on the right comes from the outcome of the various
293+
# test_reports that make up this test case
294+
# we are just carrying it over to the final report.
295+
test_report.outcome = report_outcome
296+
test_report.when = "call"
297+
test_report.nodeid = test_name
298+
test_report.longrepr = full_text
299+
test_report.extra = extras
300+
test_report.duration = duration
301+
302+
if wasxfail:
303+
test_report.wasxfail = True
304+
305+
if test_report.outcome == "passed":
306+
self.append_passed(test_report)
307+
elif test_report.outcome == "skipped":
308+
self.append_skipped(test_report)
309+
elif test_report.outcome == "failed":
310+
test_report.when = failure_when
311+
self.append_failed(test_report)
312+
313+
def pytest_runtest_logreport(self, report):
314+
self.reports[report.nodeid].append(report)
315+
316+
def pytest_collectreport(self, report):
317+
if report.failed:
318+
self.append_failed(report)
319+
320+
def pytest_sessionstart(self, session):
321+
self.suite_start_time = time.time()
322+
323+
def pytest_sessionfinish(self, session):
324+
self._post_process_reports()
325+
report_content = self._generate_report(session)
326+
self._save_report(report_content)
327+
328+
def pytest_terminal_summary(self, terminalreporter):
329+
terminalreporter.write_sep("-", f"generated html file: file://{self.logfile}")

src/pytest_html/outcome.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from py.xml import html
2+
3+
4+
class Outcome:
5+
def __init__(self, outcome, total=0, label=None, test_result=None, class_html=None):
6+
self.outcome = outcome
7+
self.label = label or outcome
8+
self.class_html = class_html or outcome
9+
self.total = total
10+
self.test_result = test_result or outcome
11+
12+
self.generate_checkbox()
13+
self.generate_summary_item()
14+
15+
def generate_checkbox(self):
16+
checkbox_kwargs = {"data-test-result": self.test_result.lower()}
17+
if self.total == 0:
18+
checkbox_kwargs["disabled"] = "true"
19+
20+
self.checkbox = html.input(
21+
type="checkbox",
22+
checked="true",
23+
onChange="filterTable(this)",
24+
name="filter_checkbox",
25+
class_="filter",
26+
hidden="true",
27+
**checkbox_kwargs,
28+
)
29+
30+
def generate_summary_item(self):
31+
self.summary_item = html.span(
32+
f"{self.total} {self.label}", class_=self.class_html
33+
)

0 commit comments

Comments
 (0)