plugin.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  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 __future__ import absolute_import
  5. from base64 import b64encode, b64decode
  6. import datetime
  7. import json
  8. import os
  9. import pkg_resources
  10. import sys
  11. import time
  12. import bisect
  13. import hashlib
  14. import warnings
  15. try:
  16. from ansi2html import Ansi2HTMLConverter, style
  17. ANSI = True
  18. except ImportError:
  19. # ansi2html is not installed
  20. ANSI = False
  21. from py.xml import html, raw
  22. from . import extras
  23. from . import __version__, __pypi_url__
  24. PY3 = sys.version_info[0] == 3
  25. # Python 2.X and 3.X compatibility
  26. if PY3:
  27. basestring = str
  28. from html import escape
  29. else:
  30. from codecs import open
  31. from cgi import escape
  32. def pytest_addhooks(pluginmanager):
  33. from . import hooks
  34. pluginmanager.add_hookspecs(hooks)
  35. def pytest_addoption(parser):
  36. group = parser.getgroup('terminal reporting')
  37. group.addoption('--html', action='store', dest='htmlpath',
  38. metavar='path', default=None,
  39. help='create html report file at given path.')
  40. group.addoption('--self-contained-html', action='store_true',
  41. help='create a self-contained html file containing all '
  42. 'necessary styles, scripts, and images - this means '
  43. 'that the report may not render or function where CSP '
  44. 'restrictions are in place (see '
  45. 'https://developer.mozilla.org/docs/Web/Security/CSP)')
  46. def pytest_configure(config):
  47. htmlpath = config.option.htmlpath
  48. # prevent opening htmlpath on slave nodes (xdist)
  49. if htmlpath and not hasattr(config, 'slaveinput'):
  50. config._html = HTMLReport(htmlpath, config)
  51. config.pluginmanager.register(config._html)
  52. def pytest_unconfigure(config):
  53. html = getattr(config, '_html', None)
  54. if html:
  55. del config._html
  56. config.pluginmanager.unregister(html)
  57. def data_uri(content, mime_type='text/plain', charset='utf-8'):
  58. data = b64encode(content.encode(charset)).decode('ascii')
  59. return 'data:{0};charset={1};base64,{2}'.format(mime_type, charset, data)
  60. class HTMLReport(object):
  61. def __init__(self, logfile, config):
  62. logfile = os.path.expanduser(os.path.expandvars(logfile))
  63. self.logfile = os.path.abspath(logfile)
  64. self.test_logs = []
  65. self.results = []
  66. self.errors = self.failed = 0
  67. self.passed = self.skipped = 0
  68. self.xfailed = self.xpassed = 0
  69. has_rerun = config.pluginmanager.hasplugin('rerunfailures')
  70. self.rerun = 0 if has_rerun else None
  71. self.self_contained = config.getoption('self_contained_html')
  72. self.config = config
  73. class TestResult:
  74. def __init__(self, outcome, report, logfile, config):
  75. self.test_id = report.nodeid
  76. if report.when != 'call':
  77. self.test_id = '::'.join([report.nodeid, report.when])
  78. self.time = getattr(report, 'duration', 0.0)
  79. self.outcome = outcome
  80. self.additional_html = []
  81. self.links_html = []
  82. self.self_contained = config.getoption('self_contained_html')
  83. self.logfile = logfile
  84. self.config = config
  85. self.row_table = self.row_extra = None
  86. test_index = hasattr(report, 'rerun') and report.rerun + 1 or 0
  87. for extra_index, extra in enumerate(getattr(report, 'extra', [])):
  88. self.append_extra_html(extra, extra_index, test_index)
  89. self.append_log_html(report, self.additional_html)
  90. cells = [
  91. html.td(self.outcome, class_='col-result'),
  92. html.td(self.test_id, class_='col-name'),
  93. html.td('{0:.2f}'.format(self.time), class_='col-duration'),
  94. html.td(self.links_html, class_='col-links')]
  95. self.config.hook.pytest_html_results_table_row(
  96. report=report, cells=cells)
  97. self.config.hook.pytest_html_results_table_html(
  98. report=report, data=self.additional_html)
  99. if len(cells) > 0:
  100. self.row_table = html.tr(cells)
  101. self.row_extra = html.tr(html.td(self.additional_html,
  102. class_='extra', colspan=len(cells)))
  103. def __lt__(self, other):
  104. order = ('Error', 'Failed', 'Rerun', 'XFailed',
  105. 'XPassed', 'Skipped', 'Passed')
  106. return order.index(self.outcome) < order.index(other.outcome)
  107. def create_asset(self, content, extra_index,
  108. test_index, file_extension, mode='w'):
  109. hash_key = ''.join([self.test_id, str(extra_index),
  110. str(test_index)]).encode('utf-8')
  111. hash_generator = hashlib.md5()
  112. hash_generator.update(hash_key)
  113. asset_file_name = '{0}.{1}'.format(hash_generator.hexdigest(),
  114. file_extension)
  115. asset_path = os.path.join(os.path.dirname(self.logfile),
  116. 'assets', asset_file_name)
  117. if not os.path.exists(os.path.dirname(asset_path)):
  118. os.makedirs(os.path.dirname(asset_path))
  119. relative_path = '{0}/{1}'.format('assets', asset_file_name)
  120. kwargs = {'encoding': 'utf-8'} if 'b' not in mode else {}
  121. with open(asset_path, mode, **kwargs) as f:
  122. f.write(content)
  123. return relative_path
  124. def append_extra_html(self, extra, extra_index, test_index):
  125. href = None
  126. if extra.get('format') == extras.FORMAT_IMAGE:
  127. content = extra.get('content')
  128. if content.startswith(('file', 'http')) or \
  129. os.path.isfile(content):
  130. if self.self_contained:
  131. warnings.warn('Self-contained HTML report '
  132. 'includes link to external '
  133. 'resource: {}'.format(content))
  134. html_div = html.a(html.img(src=content), href=content)
  135. elif self.self_contained:
  136. src = 'data:{0};base64,{1}'.format(
  137. extra.get('mime_type'),
  138. content)
  139. html_div = html.img(src=src)
  140. else:
  141. if PY3:
  142. content = b64decode(content.encode('utf-8'))
  143. else:
  144. content = b64decode(content)
  145. href = src = self.create_asset(
  146. content, extra_index, test_index,
  147. extra.get('extension'), 'wb')
  148. html_div = html.a(html.img(src=src), href=href)
  149. self.additional_html.append(html.div(html_div, class_='image'))
  150. elif extra.get('format') == extras.FORMAT_HTML:
  151. self.additional_html.append(html.div(
  152. raw(extra.get('content'))))
  153. elif extra.get('format') == extras.FORMAT_JSON:
  154. content = json.dumps(extra.get('content'))
  155. if self.self_contained:
  156. href = data_uri(content,
  157. mime_type=extra.get('mime_type'))
  158. else:
  159. href = self.create_asset(content, extra_index,
  160. test_index,
  161. extra.get('extension'))
  162. elif extra.get('format') == extras.FORMAT_TEXT:
  163. content = extra.get('content')
  164. if isinstance(content, bytes):
  165. content = content.decode('utf-8')
  166. if self.self_contained:
  167. href = data_uri(content)
  168. else:
  169. href = self.create_asset(content, extra_index,
  170. test_index,
  171. extra.get('extension'))
  172. elif extra.get('format') == extras.FORMAT_URL:
  173. href = extra.get('content')
  174. if href is not None:
  175. self.links_html.append(html.a(
  176. extra.get('name'),
  177. class_=extra.get('format'),
  178. href=href,
  179. target='_blank'))
  180. self.links_html.append(' ')
  181. def append_log_html(self, report, additional_html):
  182. log = html.div(class_='log')
  183. if report.longrepr:
  184. for line in report.longreprtext.splitlines():
  185. separator = line.startswith('_ ' * 10)
  186. if separator:
  187. log.append(line[:80])
  188. else:
  189. exception = line.startswith("E ")
  190. if exception:
  191. log.append(html.span(raw(escape(line)),
  192. class_='error'))
  193. else:
  194. log.append(raw(escape(line)))
  195. log.append(html.br())
  196. for section in report.sections:
  197. header, content = map(escape, section)
  198. log.append(' {0} '.format(header).center(80, '-'))
  199. log.append(html.br())
  200. if ANSI:
  201. converter = Ansi2HTMLConverter(inline=False, escaped=False)
  202. content = converter.convert(content, full=False)
  203. log.append(raw(content))
  204. if len(log) == 0:
  205. log = html.div(class_='empty log')
  206. log.append('No log output captured.')
  207. additional_html.append(log)
  208. def _appendrow(self, outcome, report):
  209. result = self.TestResult(outcome, report, self.logfile, self.config)
  210. if result.row_table is not None:
  211. index = bisect.bisect_right(self.results, result)
  212. self.results.insert(index, result)
  213. tbody = html.tbody(
  214. result.row_table,
  215. class_='{0} results-table-row'.format(result.outcome.lower()))
  216. if result.row_extra is not None:
  217. tbody.append(result.row_extra)
  218. self.test_logs.insert(index, tbody)
  219. def append_passed(self, report):
  220. if report.when == 'call':
  221. if hasattr(report, "wasxfail"):
  222. self.xpassed += 1
  223. self._appendrow('XPassed', report)
  224. else:
  225. self.passed += 1
  226. self._appendrow('Passed', report)
  227. def append_failed(self, report):
  228. if report.when == "call":
  229. if hasattr(report, "wasxfail"):
  230. # pytest < 3.0 marked xpasses as failures
  231. self.xpassed += 1
  232. self._appendrow('XPassed', report)
  233. else:
  234. self.failed += 1
  235. self._appendrow('Failed', report)
  236. else:
  237. self.errors += 1
  238. self._appendrow('Error', report)
  239. def append_skipped(self, report):
  240. if hasattr(report, "wasxfail"):
  241. self.xfailed += 1
  242. self._appendrow('XFailed', report)
  243. else:
  244. self.skipped += 1
  245. self._appendrow('Skipped', report)
  246. def append_other(self, report):
  247. # For now, the only "other" the plugin give support is rerun
  248. self.rerun += 1
  249. self._appendrow('Rerun', report)
  250. def _generate_report(self, session):
  251. suite_stop_time = time.time()
  252. suite_time_delta = suite_stop_time - self.suite_start_time
  253. numtests = self.passed + self.failed + self.xpassed + self.xfailed
  254. generated = datetime.datetime.now()
  255. self.style_css = pkg_resources.resource_string(
  256. __name__, os.path.join('resources', 'style.css'))
  257. if PY3:
  258. self.style_css = self.style_css.decode('utf-8')
  259. if ANSI:
  260. ansi_css = [
  261. '\n/******************************',
  262. ' * ANSI2HTML STYLES',
  263. ' ******************************/\n']
  264. ansi_css.extend([str(r) for r in style.get_styles()])
  265. self.style_css += '\n'.join(ansi_css)
  266. css_href = '{0}/{1}'.format('assets', 'style.css')
  267. html_css = html.link(href=css_href, rel='stylesheet',
  268. type='text/css')
  269. if self.self_contained:
  270. html_css = html.style(raw(self.style_css))
  271. head = html.head(
  272. html.meta(charset='utf-8'),
  273. html.title('Test Report'),
  274. html_css)
  275. class Outcome:
  276. def __init__(self, outcome, total=0, label=None,
  277. test_result=None, class_html=None):
  278. self.outcome = outcome
  279. self.label = label or outcome
  280. self.class_html = class_html or outcome
  281. self.total = total
  282. self.test_result = test_result or outcome
  283. self.generate_checkbox()
  284. self.generate_summary_item()
  285. def generate_checkbox(self):
  286. checkbox_kwargs = {'data-test-result':
  287. self.test_result.lower()}
  288. if self.total == 0:
  289. checkbox_kwargs['disabled'] = 'true'
  290. self.checkbox = html.input(type='checkbox',
  291. checked='true',
  292. onChange='filter_table(this)',
  293. name='filter_checkbox',
  294. class_='filter',
  295. hidden='true',
  296. **checkbox_kwargs)
  297. def generate_summary_item(self):
  298. self.summary_item = html.span('{0} {1}'.
  299. format(self.total, self.label),
  300. class_=self.class_html)
  301. outcomes = [Outcome('passed', self.passed),
  302. Outcome('skipped', self.skipped),
  303. Outcome('failed', self.failed),
  304. Outcome('error', self.errors, label='errors'),
  305. Outcome('xfailed', self.xfailed,
  306. label='expected failures'),
  307. Outcome('xpassed', self.xpassed,
  308. label='unexpected passes')]
  309. if self.rerun is not None:
  310. outcomes.append(Outcome('rerun', self.rerun))
  311. summary = [html.h2('Summary'), html.p(
  312. '{0} tests ran in {1:.2f} seconds. '.format(
  313. numtests, suite_time_delta)),
  314. html.p('(Un)check the boxes to filter the results.',
  315. class_='filter',
  316. hidden='true')]
  317. for i, outcome in enumerate(outcomes, start=1):
  318. summary.append(outcome.checkbox)
  319. summary.append(outcome.summary_item)
  320. if i < len(outcomes):
  321. summary.append(', ')
  322. cells = [
  323. html.th('Result',
  324. class_='sortable result initial-sort',
  325. col='result'),
  326. html.th('Test', class_='sortable', col='name'),
  327. html.th('Duration', class_='sortable numeric', col='duration'),
  328. html.th('Links')]
  329. session.config.hook.pytest_html_results_table_header(cells=cells)
  330. results = [html.h2('Results'), html.table([html.thead(
  331. html.tr(cells),
  332. html.tr([
  333. html.th('No results found. Try to check the filters',
  334. colspan=len(cells))],
  335. id='not-found-message', hidden='true'),
  336. id='results-table-head'),
  337. self.test_logs], id='results-table')]
  338. main_js = pkg_resources.resource_string(
  339. __name__, os.path.join('resources', 'main.js'))
  340. if PY3:
  341. main_js = main_js.decode('utf-8')
  342. body = html.body(
  343. html.script(raw(main_js)),
  344. html.p('Report generated on {0} at {1} by'.format(
  345. generated.strftime('%d-%b-%Y'),
  346. generated.strftime('%H:%M:%S')),
  347. html.a(' pytest-html', href=__pypi_url__),
  348. ' v{0}'.format(__version__)),
  349. onLoad='init()')
  350. body.extend(self._generate_environment(session.config))
  351. body.extend(summary)
  352. body.extend(results)
  353. doc = html.html(head, body)
  354. unicode_doc = u'<!DOCTYPE html>\n{0}'.format(doc.unicode(indent=2))
  355. if PY3:
  356. # Fix encoding issues, e.g. with surrogates
  357. unicode_doc = unicode_doc.encode('utf-8',
  358. errors='xmlcharrefreplace')
  359. unicode_doc = unicode_doc.decode('utf-8')
  360. return unicode_doc
  361. def _generate_environment(self, config):
  362. if not hasattr(config, '_metadata') or config._metadata is None:
  363. return []
  364. metadata = config._metadata
  365. environment = [html.h2('Environment')]
  366. rows = []
  367. for key in [k for k in sorted(metadata.keys()) if metadata[k]]:
  368. value = metadata[key]
  369. if isinstance(value, basestring) and value.startswith('http'):
  370. value = html.a(value, href=value, target='_blank')
  371. rows.append(html.tr(html.td(key), html.td(value)))
  372. environment.append(html.table(rows, id='environment'))
  373. return environment
  374. def _save_report(self, report_content):
  375. dir_name = os.path.dirname(self.logfile)
  376. assets_dir = os.path.join(dir_name, 'assets')
  377. if not os.path.exists(dir_name):
  378. os.makedirs(dir_name)
  379. if not self.self_contained and not os.path.exists(assets_dir):
  380. os.makedirs(assets_dir)
  381. with open(self.logfile, 'w', encoding='utf-8') as f:
  382. f.write(report_content)
  383. if not self.self_contained:
  384. style_path = os.path.join(assets_dir, 'style.css')
  385. with open(style_path, 'w', encoding='utf-8') as f:
  386. f.write(self.style_css)
  387. def pytest_runtest_logreport(self, report):
  388. if report.passed:
  389. self.append_passed(report)
  390. elif report.failed:
  391. self.append_failed(report)
  392. elif report.skipped:
  393. self.append_skipped(report)
  394. else:
  395. self.append_other(report)
  396. def pytest_sessionstart(self, session):
  397. self.suite_start_time = time.time()
  398. def pytest_sessionfinish(self, session):
  399. report_content = self._generate_report(session)
  400. self._save_report(report_content)
  401. def pytest_terminal_summary(self, terminalreporter):
  402. terminalreporter.write_sep('-', 'generated html file: {0}'.format(
  403. self.logfile))