pytest-xhtml 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ try:
2
+ from . import __version # type: ignore
3
+
4
+ __version__ = __version.version
5
+ except ImportError:
6
+ # package is not built with setuptools_scm
7
+ __version__ = "unknown"
8
+
9
+ __pypi_url__ = "https://pypi.python.org/pypi/pytest-html"
@@ -0,0 +1,21 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
6
+ TYPE_CHECKING = False
7
+ if TYPE_CHECKING:
8
+ from typing import Tuple
9
+ from typing import Union
10
+
11
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
12
+ else:
13
+ VERSION_TUPLE = object
14
+
15
+ version: str
16
+ __version__: str
17
+ __version_tuple__: VERSION_TUPLE
18
+ version_tuple: VERSION_TUPLE
19
+
20
+ __version__ = version = '0.1.0'
21
+ __version_tuple__ = version_tuple = (0, 1, 0)
@@ -0,0 +1,377 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+ import datetime
5
+ import json
6
+ import math
7
+ import os
8
+ import re
9
+ import time
10
+ import warnings
11
+ from collections import defaultdict
12
+ from html import escape
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ from pytest_xhtml import __version__
18
+ from pytest_xhtml import extras
19
+
20
+
21
+ class BaseReport:
22
+ def __init__(self, report_path, config, report_data, template, css):
23
+ self._report_path = (
24
+ Path.cwd() / Path(os.path.expandvars(report_path)).expanduser()
25
+ )
26
+ self._report_path.parent.mkdir(parents=True, exist_ok=True)
27
+ self._config = config
28
+ self._template = template
29
+ self._css = css
30
+ self._max_asset_filename_length = int(
31
+ config.getini("max_asset_filename_length")
32
+ )
33
+
34
+ self._reports = defaultdict(dict)
35
+ self._report = report_data
36
+ self._report.title = self._report_path.name
37
+ self._suite_start_time = time.time()
38
+
39
+ @property
40
+ def css(self):
41
+ # implement in subclasses
42
+ return
43
+
44
+ def _asset_filename(self, test_id, extra_index, test_index, file_extension):
45
+ return "{}_{}_{}.{}".format(
46
+ re.sub(r"[^\w.]", "_", test_id),
47
+ str(extra_index),
48
+ str(test_index),
49
+ file_extension,
50
+ )[-self._max_asset_filename_length :]
51
+
52
+ def _generate_report(self, self_contained=False):
53
+ generated = datetime.datetime.now()
54
+ test_data = self._report.data
55
+ test_data = json.dumps(test_data)
56
+ rendered_report = self._template.render(
57
+ title=self._report.title,
58
+ date=generated.strftime("%d-%b-%Y"),
59
+ time=generated.strftime("%H:%M:%S"),
60
+ version=__version__,
61
+ styles=self.css,
62
+ run_count=self._run_count(),
63
+ running_state=self._report.running_state,
64
+ self_contained=self_contained,
65
+ outcomes=self._report.outcomes,
66
+ test_data=test_data,
67
+ table_head=self._report.table_header,
68
+ additional_summary=self._report.additional_summary,
69
+ )
70
+
71
+ self._write_report(rendered_report)
72
+
73
+ def _generate_environment(self):
74
+ try:
75
+ from pytest_metadata.plugin import metadata_key
76
+
77
+ metadata = self._config.stash[metadata_key]
78
+ except ImportError:
79
+ # old version of pytest-metadata
80
+ metadata = self._config._metadata
81
+ warnings.warn(
82
+ "'pytest-metadata < 3.0.0' is deprecated and support will be dropped in next major version",
83
+ DeprecationWarning,
84
+ )
85
+
86
+ for key in metadata.keys():
87
+ value = metadata[key]
88
+ if self._is_redactable_environment_variable(key):
89
+ black_box_ascii_value = 0x2593
90
+ metadata[key] = "".join(chr(black_box_ascii_value) for _ in str(value))
91
+
92
+ return metadata
93
+
94
+ def _is_redactable_environment_variable(self, environment_variable):
95
+ redactable_regexes = self._config.getini("environment_table_redact_list")
96
+ for redactable_regex in redactable_regexes:
97
+ if re.match(redactable_regex, environment_variable):
98
+ return True
99
+
100
+ return False
101
+
102
+ def _data_content(self, *args, **kwargs):
103
+ pass
104
+
105
+ def _media_content(self, *args, **kwargs):
106
+ pass
107
+
108
+ def _process_extras(self, report, test_id):
109
+ test_index = hasattr(report, "rerun") and report.rerun + 1 or 0
110
+ report_extras = getattr(report, "extras", [])
111
+ for extra_index, extra in enumerate(report_extras):
112
+ content = extra["content"]
113
+ asset_name = self._asset_filename(
114
+ test_id.encode("utf-8").decode("unicode_escape"),
115
+ extra_index,
116
+ test_index,
117
+ extra["extension"],
118
+ )
119
+ if extra["format_type"] == extras.FORMAT_JSON:
120
+ content = json.dumps(content)
121
+ extra["content"] = self._data_content(
122
+ content, asset_name=asset_name, mime_type=extra["mime_type"]
123
+ )
124
+
125
+ if extra["format_type"] == extras.FORMAT_TEXT:
126
+ if isinstance(content, bytes):
127
+ content = content.decode("utf-8")
128
+ extra["content"] = self._data_content(
129
+ content, asset_name=asset_name, mime_type=extra["mime_type"]
130
+ )
131
+
132
+ if extra["format_type"] in [extras.FORMAT_IMAGE, extras.FORMAT_VIDEO]:
133
+ extra["content"] = self._media_content(
134
+ content, asset_name=asset_name, mime_type=extra["mime_type"]
135
+ )
136
+
137
+ return report_extras
138
+
139
+ def _write_report(self, rendered_report):
140
+ with self._report_path.open("w", encoding="utf-8") as f:
141
+ f.write(rendered_report)
142
+
143
+ def _run_count(self):
144
+ relevant_outcomes = ["passed", "failed", "xpassed", "xfailed"]
145
+ counts = 0
146
+ for outcome in self._report.outcomes.keys():
147
+ if outcome in relevant_outcomes:
148
+ counts += self._report.outcomes[outcome]["value"]
149
+
150
+ plural = counts > 1
151
+ duration = _format_duration(self._report.total_duration)
152
+
153
+ if self._report.running_state == "finished":
154
+ return f"{counts} {'tests' if plural else 'test'} took {duration}."
155
+
156
+ return f"{counts}/{self._report.collected_items} {'tests' if plural else 'test'} done."
157
+
158
+ def _hydrate_data(self, data, cells):
159
+ for index, cell in enumerate(cells):
160
+ # extract column name and data if column is sortable
161
+ if "sortable" in self._report.table_header[index]:
162
+ name_match = re.search(r"col-(\w+)", cell)
163
+ data_match = re.search(r"<td.*?>(.*?)</td>", cell)
164
+ if name_match and data_match:
165
+ data[name_match.group(1)] = data_match.group(1)
166
+
167
+ @pytest.hookimpl(trylast=True)
168
+ def pytest_sessionstart(self, session):
169
+ self._report.set_data("environment", self._generate_environment())
170
+
171
+ session.config.hook.pytest_xhtml_report_title(report=self._report)
172
+
173
+ headers = self._report.table_header
174
+ session.config.hook.pytest_xhtml_results_table_header(cells=headers)
175
+ self._report.table_header = _fix_py(headers)
176
+
177
+ self._report.running_state = "started"
178
+ if self._config.getini("generate_report_on_test"):
179
+ self._generate_report()
180
+
181
+ @pytest.hookimpl(trylast=True)
182
+ def pytest_sessionfinish(self, session):
183
+ session.config.hook.pytest_xhtml_results_summary(
184
+ prefix=self._report.additional_summary["prefix"],
185
+ summary=self._report.additional_summary["summary"],
186
+ postfix=self._report.additional_summary["postfix"],
187
+ session=session,
188
+ )
189
+ self._report.running_state = "finished"
190
+ suite_stop_time = time.time()
191
+ self._report.total_duration = suite_stop_time - self._suite_start_time
192
+ self._generate_report()
193
+
194
+ @pytest.hookimpl(trylast=True)
195
+ def pytest_terminal_summary(self, terminalreporter):
196
+ terminalreporter.write_sep(
197
+ "-",
198
+ f"Generated html report: {self._report_path.as_uri()}",
199
+ )
200
+
201
+ @pytest.hookimpl(trylast=True)
202
+ def pytest_collectreport(self, report):
203
+ if report.failed:
204
+ self._process_report(report, 0, [])
205
+
206
+ @pytest.hookimpl(trylast=True)
207
+ def pytest_collection_finish(self, session):
208
+ self._report.collected_items = len(session.items)
209
+
210
+ @pytest.hookimpl(trylast=True)
211
+ def pytest_runtest_logreport(self, report):
212
+ if hasattr(report, "duration_formatter"):
213
+ warnings.warn(
214
+ "'duration_formatter' has been removed and no longer has any effect!"
215
+ "Please use the 'pytest_xhtml_duration_format' hook instead.",
216
+ DeprecationWarning,
217
+ )
218
+
219
+ # "reruns" makes this code a mess.
220
+ # We store each combination of when and outcome
221
+ # exactly once, unless that outcome is a "rerun"
222
+ # then we store all of them.
223
+ key = (report.when, report.outcome)
224
+ if report.outcome == "rerun":
225
+ if key not in self._reports[report.nodeid]:
226
+ self._reports[report.nodeid][key] = list()
227
+ self._reports[report.nodeid][key].append(report)
228
+ else:
229
+ self._reports[report.nodeid][key] = [report]
230
+
231
+ finished = report.when == "teardown" and report.outcome != "rerun"
232
+ if not finished:
233
+ return
234
+
235
+ # Calculate total duration for a single test.
236
+ # This is needed to add the "teardown" duration
237
+ # to tests total duration.
238
+ test_duration = 0
239
+ for key, reports in self._reports[report.nodeid].items():
240
+ _, outcome = key
241
+ if outcome != "rerun":
242
+ test_duration += reports[0].duration
243
+
244
+ processed_extras = []
245
+ for key, reports in self._reports[report.nodeid].items():
246
+ when, _ = key
247
+ for each in reports:
248
+ test_id = report.nodeid
249
+ if when != "call":
250
+ test_id += f"::{when}"
251
+ processed_extras += self._process_extras(each, test_id)
252
+
253
+ for key, reports in self._reports[report.nodeid].items():
254
+ when, _ = key
255
+ for each in reports:
256
+ dur = test_duration if when == "call" else each.duration
257
+ self._process_report(each, dur, processed_extras)
258
+
259
+ if self._config.getini("generate_report_on_test"):
260
+ self._generate_report()
261
+
262
+ def _process_report(self, report, duration, processed_extras):
263
+ outcome = _process_outcome(report)
264
+ try:
265
+ # hook returns as list for some reason
266
+ formatted_duration = self._config.hook.pytest_xhtml_duration_format(
267
+ duration=duration
268
+ )[0]
269
+ except IndexError:
270
+ formatted_duration = _format_duration(duration)
271
+
272
+ test_id = report.nodeid
273
+ if report.when != "call":
274
+ test_id += f"::{report.when}"
275
+
276
+ data = {
277
+ "extras": processed_extras,
278
+ }
279
+
280
+ links = [
281
+ extra
282
+ for extra in data["extras"]
283
+ if extra["format_type"] in ["json", "text", "url"]
284
+ ]
285
+ cells = [
286
+ f'<td class="col-result">{outcome}</td>',
287
+ f'<td class="col-testId">{test_id}</td>',
288
+ f'<td class="col-duration">{formatted_duration}</td>',
289
+ f'<td class="col-links">{_process_links(links)}</td>',
290
+ ]
291
+ self._config.hook.pytest_xhtml_results_table_row(report=report, cells=cells)
292
+ if not cells:
293
+ return
294
+
295
+ cells = _fix_py(cells)
296
+ self._hydrate_data(data, cells)
297
+ data["resultsTableRow"] = cells
298
+
299
+ processed_logs = _process_logs(report)
300
+ self._config.hook.pytest_xhtml_results_table_html(
301
+ report=report, data=processed_logs
302
+ )
303
+
304
+ self._report.add_test(data, report, outcome, processed_logs)
305
+
306
+
307
+ def _format_duration(duration):
308
+ if duration < 1:
309
+ return f"{round(duration * 1000)} ms"
310
+
311
+ hours = math.floor(duration / 3600)
312
+ remaining_seconds = duration % 3600
313
+ minutes = math.floor(remaining_seconds / 60)
314
+ remaining_seconds = remaining_seconds % 60
315
+ seconds = round(remaining_seconds)
316
+
317
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
318
+
319
+
320
+ def _is_error(report):
321
+ return (
322
+ report.when in ["setup", "teardown", "collect"] and report.outcome == "failed"
323
+ )
324
+
325
+
326
+ def _process_logs(report):
327
+ log = []
328
+ if report.longreprtext:
329
+ log.append(escape(report.longreprtext) + "\n")
330
+ # Don't add captured output to reruns
331
+ if report.outcome != "rerun":
332
+ for section in report.sections:
333
+ header, content = map(escape, section)
334
+ log.append(f"{' ' + header + ' ':-^80}\n{content}")
335
+
336
+ # weird formatting related to logs
337
+ if "log" in header:
338
+ log.append("")
339
+ if "call" in header:
340
+ log.append("")
341
+ if not log:
342
+ log.append("No log output captured.")
343
+ return log
344
+
345
+
346
+ def _process_outcome(report):
347
+ if _is_error(report):
348
+ return "Error"
349
+ if hasattr(report, "wasxfail"):
350
+ if report.outcome in ["passed", "failed"]:
351
+ return "XPassed"
352
+ if report.outcome == "skipped":
353
+ return "XFailed"
354
+
355
+ return report.outcome.capitalize()
356
+
357
+
358
+ def _process_links(links):
359
+ a_tag = '<a target="_blank" href="{content}" class="col-links__extra {format_type}">{name}</a>'
360
+ return "".join([a_tag.format_map(link) for link in links])
361
+
362
+
363
+ def _fix_py(cells):
364
+ # backwards-compat
365
+ new_cells = []
366
+ for html in cells:
367
+ if not isinstance(html, str):
368
+ if html.__module__.startswith("py."):
369
+ warnings.warn(
370
+ "The 'py' module is deprecated and support "
371
+ "will be removed in a future release.",
372
+ DeprecationWarning,
373
+ )
374
+ html = str(html)
375
+ html = html.replace("col=", "data-column-type=")
376
+ new_cells.append(html)
377
+ return new_cells
pytest_xhtml/extras.py ADDED
@@ -0,0 +1,77 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+ from typing import Optional
5
+
6
+ FORMAT_HTML = "html"
7
+ FORMAT_IMAGE = "image"
8
+ FORMAT_JSON = "json"
9
+ FORMAT_TEXT = "text"
10
+ FORMAT_URL = "url"
11
+ FORMAT_VIDEO = "video"
12
+
13
+
14
+ def extra(
15
+ content: str,
16
+ format_type: str,
17
+ name: Optional[str] = None,
18
+ mime_type: Optional[str] = None,
19
+ extension: Optional[str] = None,
20
+ ) -> dict[str, Optional[str]]:
21
+ return {
22
+ "name": name,
23
+ "format_type": format_type,
24
+ "content": content,
25
+ "mime_type": mime_type,
26
+ "extension": extension,
27
+ }
28
+
29
+
30
+ def html(content: str) -> dict[str, Optional[str]]:
31
+ return extra(content, FORMAT_HTML)
32
+
33
+
34
+ def image(
35
+ content: str,
36
+ name: str = "Image",
37
+ mime_type: str = "image/png",
38
+ extension: str = "png",
39
+ ) -> dict[str, Optional[str]]:
40
+ return extra(content, FORMAT_IMAGE, name, mime_type, extension)
41
+
42
+
43
+ def png(content: str, name: str = "Image") -> dict[str, Optional[str]]:
44
+ return image(content, name, mime_type="image/png", extension="png")
45
+
46
+
47
+ def jpg(content: str, name: str = "Image") -> dict[str, Optional[str]]:
48
+ return image(content, name, mime_type="image/jpeg", extension="jpg")
49
+
50
+
51
+ def svg(content: str, name: str = "Image") -> dict[str, Optional[str]]:
52
+ return image(content, name, mime_type="image/svg+xml", extension="svg")
53
+
54
+
55
+ def json(content: str, name: str = "JSON") -> dict[str, Optional[str]]:
56
+ return extra(content, FORMAT_JSON, name, "application/json", "json")
57
+
58
+
59
+ def text(content: str, name: str = "Text") -> dict[str, Optional[str]]:
60
+ return extra(content, FORMAT_TEXT, name, "text/plain", "txt")
61
+
62
+
63
+ def url(content: str, name: str = "URL") -> dict[str, Optional[str]]:
64
+ return extra(content, FORMAT_URL, name)
65
+
66
+
67
+ def video(
68
+ content: str,
69
+ name: str = "Video",
70
+ mime_type: str = "video/mp4",
71
+ extension: str = "mp4",
72
+ ) -> dict[str, Optional[str]]:
73
+ return extra(content, FORMAT_VIDEO, name, mime_type, extension)
74
+
75
+
76
+ def mp4(content: str, name: str = "Video") -> dict[str, Optional[str]]:
77
+ return video(content, name)
@@ -0,0 +1,47 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+ import warnings
5
+
6
+ import pytest
7
+
8
+ extras_stash_key = pytest.StashKey[list]()
9
+
10
+
11
+ @pytest.fixture
12
+ def extra(pytestconfig):
13
+ """DEPRECATED: Add details to the HTML reports.
14
+
15
+ .. code-block:: python
16
+
17
+ import pytest_xhtml
18
+
19
+
20
+ def test_foo(extra):
21
+ extra.append(pytest_xhtml.extras.url("https://www.example.com/"))
22
+ """
23
+ warnings.warn(
24
+ "The 'extra' fixture is deprecated and will be removed in a future release"
25
+ ", use 'extras' instead.",
26
+ DeprecationWarning,
27
+ )
28
+ pytestconfig.stash[extras_stash_key] = []
29
+ yield pytestconfig.stash[extras_stash_key]
30
+ del pytestconfig.stash[extras_stash_key][:]
31
+
32
+
33
+ @pytest.fixture
34
+ def extras(pytestconfig):
35
+ """Add details to the HTML reports.
36
+
37
+ .. code-block:: python
38
+
39
+ import pytest_xhtml
40
+
41
+
42
+ def test_foo(extras):
43
+ extras.append(pytest_xhtml.extras.url("https://www.example.com/"))
44
+ """
45
+ pytestconfig.stash[extras_stash_key] = []
46
+ yield pytestconfig.stash[extras_stash_key]
47
+ del pytestconfig.stash[extras_stash_key][:]
pytest_xhtml/hooks.py ADDED
@@ -0,0 +1,27 @@
1
+ # This Source Code Form is subject to the terms of the Mozilla Public
2
+ # License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ # file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
+
5
+
6
+ def pytest_xhtml_report_title(report):
7
+ """Called before adding the title to the report"""
8
+
9
+
10
+ def pytest_xhtml_results_summary(prefix, summary, postfix, session):
11
+ """Called before adding the summary section to the report"""
12
+
13
+
14
+ def pytest_xhtml_results_table_header(cells):
15
+ """Called after building results table header."""
16
+
17
+
18
+ def pytest_xhtml_results_table_row(report, cells):
19
+ """Called after building results table row."""
20
+
21
+
22
+ def pytest_xhtml_results_table_html(report, data):
23
+ """Called after building results table additional HTML."""
24
+
25
+
26
+ def pytest_xhtml_duration_format(duration):
27
+ """Called before using the default duration formatting."""