reporters.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. # -*- coding: utf-8 -*-
  2. """!@package grass.gunittest.reporters
  3. @brief GRASS Python testing framework module for report generation
  4. Copyright (C) 2014 by the GRASS Development Team
  5. This program is free software under the GNU General Public
  6. License (>=v2). Read the file COPYING that comes with GRASS GIS
  7. for details.
  8. @author Vaclav Petras
  9. """
  10. import os
  11. import datetime
  12. import xml.sax.saxutils as saxutils
  13. import xml.etree.ElementTree as et
  14. import subprocess
  15. from .utils import ensure_dir
  16. def get_source_url(path, revision, line=None):
  17. """
  18. :param path: directory or file path relative to remote repository root
  19. :param revision: SVN revision (should be a number)
  20. :param line: line in the file (should be None for directories)
  21. """
  22. tracurl = 'http://trac.osgeo.org/grass/browser/'
  23. if line:
  24. return '{tracurl}{path}?rev={revision}#L{line}'.format(**locals())
  25. else:
  26. return '{tracurl}{path}?rev={revision}'.format(**locals())
  27. def html_escape(text):
  28. """Escape ``'&'``, ``'<'``, and ``'>'`` in a string of data."""
  29. return saxutils.escape(text)
  30. def html_unescape(text):
  31. """Unescape ``'&amp;'``, ``'&lt;'``, and ``'&gt;'`` in a string of data."""
  32. return saxutils.unescape(text)
  33. def color_error_line(line):
  34. if line.startswith('ERROR: '):
  35. # TODO: use CSS class
  36. # ignoring the issue with \n at the end, HTML don't mind
  37. line = '<span style="color: red">' + line + "</span>"
  38. if line.startswith('FAIL: '):
  39. # TODO: use CSS class
  40. # ignoring the issue with \n at the end, HTML don't mind
  41. line = '<span style="color: red">' + line + "</span>"
  42. if line.startswith('WARNING: '):
  43. # TODO: use CSS class
  44. # ignoring the issue with \n at the end, HTML don't mind
  45. line = '<span style="color: blue">' + line + "</span>"
  46. #if line.startswith('Traceback ('):
  47. # line = '<span style="color: red">' + line + "</span>"
  48. return line
  49. def get_svn_revision():
  50. """Get SVN revision number
  51. :returns: SVN revision number as string or None if it is
  52. not possible to get
  53. """
  54. # TODO: here should be starting directory
  55. # but now we are using current as starting
  56. p = subprocess.Popen(['svnversion', '.'],
  57. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  58. stdout, stderr = p.communicate()
  59. rc = p.poll()
  60. if not rc:
  61. stdout = stdout.strip()
  62. if stdout.endswith('M'):
  63. stdout = stdout[:-1]
  64. if ':' in stdout:
  65. # the first one is the one of source code
  66. stdout = stdout.split(':')[0]
  67. return stdout
  68. else:
  69. return None
  70. def get_svn_info():
  71. """Get important information from ``svn info``
  72. :returns: SVN info as dictionary or None
  73. if it is not possible to obtain it
  74. """
  75. try:
  76. # TODO: introduce directory, not only current
  77. p = subprocess.Popen(['svn', 'info', '.', '--xml'],
  78. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  79. stdout, stderr = p.communicate()
  80. rc = p.poll()
  81. info = {}
  82. if not rc:
  83. root = et.fromstring(stdout)
  84. # TODO: get also date if this make sense
  85. # expecting only one <entry> element
  86. entry = root.find('entry')
  87. info['revision'] = entry.get('revision')
  88. info['url'] = entry.find('url').text
  89. relurl = entry.find('relative-url')
  90. # element which is not found is None
  91. # empty element would be bool(el) == False
  92. if relurl is not None:
  93. relurl = relurl.text
  94. # relative path has ^ at the beginning in SVN version 1.8.8
  95. if relurl.startswith('^'):
  96. relurl = relurl[1:]
  97. else:
  98. # SVN version 1.8.8 supports relative-url but older do not
  99. # so, get relative part from absolute URL
  100. const_url_part = 'https://svn.osgeo.org/grass/'
  101. relurl = info['url'][len(const_url_part):]
  102. info['relative-url'] = relurl
  103. return info
  104. # TODO: add this to svnversion function
  105. except OSError as e:
  106. import errno
  107. # ignore No such file or directory
  108. if e.errno != errno.ENOENT:
  109. raise
  110. return None
  111. def years_ago(date, years):
  112. # dateutil relative delte would be better but this is more portable
  113. return date - datetime.timedelta(weeks=years * 52)
  114. # TODO: these functions should be called only if we know that svn is installed
  115. # this will simplify the functions, caller must handle it anyway
  116. def get_svn_path_authors(path, from_date=None):
  117. """
  118. :returns: a set of authors
  119. """
  120. if from_date is None:
  121. # this is the SVN default for local copies
  122. revision_range = 'BASE:1'
  123. else:
  124. revision_range = 'BASE:{%s}' % from_date
  125. try:
  126. # TODO: allow also usage of --limit
  127. p = subprocess.Popen(['svn', 'log', '--xml',
  128. '--revision', revision_range, path],
  129. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  130. stdout, stderr = p.communicate()
  131. rc = p.poll()
  132. if not rc:
  133. root = et.fromstring(stdout)
  134. # TODO: get also date if this make sense
  135. # expecting only one <entry> element
  136. author_nodes = root.iterfind('*/author')
  137. authors = [n.text for n in author_nodes]
  138. return set(authors)
  139. except OSError as e:
  140. import errno
  141. # ignore No such file or directory
  142. if e.errno != errno.ENOENT:
  143. raise
  144. return None
  145. class GrassTestFilesMultiReporter(object):
  146. def __init__(self, reporters, forgiving=False):
  147. self.reporters = reporters
  148. self.forgiving = forgiving
  149. def start(self, results_dir):
  150. # TODO: no directory cleaning (self.clean_before)? now cleaned by caller
  151. # TODO: perhaps only those whoe need it should do it (even multiple times)
  152. # and there is also the delet problem
  153. ensure_dir(os.path.abspath(results_dir))
  154. for reporter in self.reporters:
  155. try:
  156. reporter.start(results_dir)
  157. except AttributeError:
  158. if self.forgiving:
  159. pass
  160. else:
  161. raise
  162. def finish(self):
  163. for reporter in self.reporters:
  164. try:
  165. reporter.finish()
  166. except AttributeError:
  167. if self.forgiving:
  168. pass
  169. else:
  170. raise
  171. def start_file_test(self, module):
  172. for reporter in self.reporters:
  173. try:
  174. reporter.start_file_test(module)
  175. except AttributeError:
  176. if self.forgiving:
  177. pass
  178. else:
  179. raise
  180. def end_file_test(self, **kwargs):
  181. for reporter in self.reporters:
  182. try:
  183. reporter.end_file_test(**kwargs)
  184. except AttributeError:
  185. if self.forgiving:
  186. pass
  187. else:
  188. raise
  189. class GrassTestFilesCountingReporter(object):
  190. def __init__(self):
  191. self.test_files = None
  192. self.files_fail = None
  193. self.files_pass = None
  194. self.file_pass_per = None
  195. self.file_fail_per = None
  196. self.main_start_time = None
  197. self.main_end_time = None
  198. self.main_time = None
  199. self.file_start_time = None
  200. self.file_end_time = None
  201. self.file_time = None
  202. self._start_file_test_called = False
  203. def start(self, results_dir):
  204. self.test_files = 0
  205. self.files_fail = 0
  206. self.files_pass = 0
  207. # this might be moved to some report start method
  208. self.main_start_time = datetime.datetime.now()
  209. def finish(self):
  210. self.main_end_time = datetime.datetime.now()
  211. self.main_time = self.main_end_time - self.main_start_time
  212. assert self.test_files == self.files_fail + self.files_pass
  213. self.file_pass_per = 100 * float(self.files_pass) / self.test_files
  214. self.file_fail_per = 100 * float(self.files_fail) / self.test_files
  215. def start_file_test(self, module):
  216. self.file_start_time = datetime.datetime.now()
  217. self._start_file_test_called = True
  218. self.test_files += 1
  219. def end_file_test(self, returncode, **kwargs):
  220. assert self._start_file_test_called
  221. self.file_end_time = datetime.datetime.now()
  222. self.file_time = self.file_end_time - self.file_start_time
  223. if returncode:
  224. self.files_fail += 1
  225. else:
  226. self.files_pass += 1
  227. self._start_file_test_called = False
  228. def percent_to_html(percent):
  229. if percent > 100 or percent < 0:
  230. return "? {:.2f}% ?".format(percent)
  231. elif percent < 40:
  232. color = 'red'
  233. elif percent < 70:
  234. color = 'orange'
  235. else:
  236. color = 'green'
  237. return '<span style="color: {color}">{percent:.0f}%</span>'.format(
  238. percent=percent, color=color)
  239. class GrassTestFilesHtmlReporter(GrassTestFilesCountingReporter):
  240. unknown_number = '<span style="font-size: 60%">unknown</span>'
  241. def __init__(self):
  242. super(GrassTestFilesHtmlReporter, self).__init__()
  243. self.main_index = None
  244. def start(self, results_dir):
  245. super(GrassTestFilesHtmlReporter, self).start(results_dir)
  246. # having all variables public although not really part of API
  247. self.main_index = open(os.path.join(results_dir, 'index.html'), 'w')
  248. # TODO: this can be moved to the counter class
  249. self.failures = 0
  250. self.errors = 0
  251. self.skiped = 0
  252. self.successes = 0
  253. self.expected_failures = 0
  254. self.unexpected_success = 0
  255. self.total = 0
  256. # TODO: skiped and unexpected success
  257. svn_info = get_svn_info()
  258. if not svn_info:
  259. svn_text = ('<span style="font-size: 60%">'
  260. 'SVN revision cannot be be obtained'
  261. '</span>')
  262. else:
  263. url = get_source_url(path=svn_info['relative-url'],
  264. revision=svn_info['revision'])
  265. svn_text = ('SVN revision'
  266. ' <a href="{url}">'
  267. '{rev}</a>'
  268. ).format(url=url, rev=svn_info['revision'])
  269. self.main_index.write('<html><body>'
  270. '<h1>Test results</h1>'
  271. '{time:%Y-%m-%d %H:%M:%S}'
  272. ' ({svn})'
  273. '<table>'
  274. '<thead><tr>'
  275. '<th>Tested directory</th>'
  276. '<th>Test file</th>'
  277. '<th>Status</th>'
  278. '<th>Tests</th><th>Successful</td>'
  279. '<th>Failed</th><th>Percent successful</th>'
  280. '</tr></thead><tbody>'.format(
  281. time=self.main_start_time,
  282. svn=svn_text))
  283. def finish(self):
  284. super(GrassTestFilesHtmlReporter, self).finish()
  285. if self.total:
  286. pass_per = 100 * (float(self.successes) / self.total)
  287. pass_per = percent_to_html(pass_per)
  288. else:
  289. pass_per = self.unknown_number
  290. tfoot = ('<tfoot>'
  291. '<tr>'
  292. '<td>Summary</td>'
  293. '<td>{nfiles} test files</td>'
  294. '<td>{nsper}</td>'
  295. '<td>{total}</td><td>{st}</td><td>{ft}</td><td>{pt}</td>'
  296. '</tr>'
  297. '</tfoot>'.format(
  298. nfiles=self.test_files,
  299. nsper=percent_to_html(self.file_pass_per),
  300. st=self.successes, ft=self.failures + self.errors,
  301. total=self.total, pt=pass_per
  302. ))
  303. summary_sentence = ('Executed {nfiles} test files in {time:}.'
  304. ' From them'
  305. ' {nsfiles} files ({nsper:.0f}%) were successful'
  306. ' and {nffiles} files ({nfper:.0f}%) failed.'
  307. .format(
  308. nfiles=self.test_files,
  309. time=self.main_time,
  310. nsfiles=self.files_pass,
  311. nffiles=self.files_fail,
  312. nsper=self.file_pass_per,
  313. nfper=self.file_fail_per))
  314. self.main_index.write('<tbody>{tfoot}</table>'
  315. '<p>{summary}</p>'
  316. '</body></html>'
  317. .format(
  318. tfoot=tfoot,
  319. summary=summary_sentence))
  320. self.main_index.close()
  321. def start_file_test(self, module):
  322. super(GrassTestFilesHtmlReporter, self).start_file_test(module)
  323. self.main_index.flush() # to get previous lines to the report
  324. def wrap_stdstream_to_html(self, infile, outfile, module, stream):
  325. before = '<html><body><h1>%s</h1><pre>' % (module.name + ' ' + stream)
  326. after = '</pre></body></html>'
  327. html = open(outfile, 'w')
  328. html.write(before)
  329. with open(infile) as text:
  330. for line in text:
  331. html.write(color_error_line(html_escape(line)))
  332. html.write(after)
  333. html.close()
  334. def returncode_to_html_text(self, returncode):
  335. if returncode:
  336. return '<span style="color: red">FAILED</span>'
  337. else:
  338. # alternatives: SUCCEEDED, passed, OK
  339. return '<span style="color: green">succeeded</span>'
  340. def returncode_to_html_sentence(self, returncode):
  341. if returncode:
  342. return ('<span style="color: red">&#x274c;</span>'
  343. ' Test failed (return code %d)' % (returncode))
  344. else:
  345. return ('<span style="color: green">&#x2713;</span>'
  346. ' Test succeeded (return code %d)' % (returncode))
  347. def end_file_test(self, module, cwd, returncode, stdout, stderr,
  348. test_summary):
  349. super(GrassTestFilesHtmlReporter, self).end_file_test(
  350. module=module, cwd=cwd, returncode=returncode,
  351. stdout=stdout, stderr=stderr)
  352. # TODO: considering others accoring to total, OK?
  353. total = test_summary.get('total', None)
  354. failures = test_summary.get('failures', 0)
  355. errors = test_summary.get('errors', 0)
  356. # Python unittest TestResult class is reporting success for no
  357. # errors or failures, so skipped, expected failures and unexpected
  358. # success are ignored
  359. # but successful tests are only total - the others
  360. # TODO: add success counter to GrassTestResult base class
  361. skipped = test_summary.get('skipped', 0)
  362. expected_failures = test_summary.get('expected_failures', 0)
  363. unexpected_successes = test_summary.get('unexpected_successes', 0)
  364. successes = test_summary.get('successes', 0)
  365. self.failures += failures
  366. self.errors += errors
  367. self.skiped += skipped
  368. self.expected_failures += expected_failures
  369. self.unexpected_success += unexpected_successes
  370. if total is not None:
  371. # success are only the clear ones
  372. # percentage is influenced by all but putting only failures to table
  373. self.successes += successes
  374. self.total += total
  375. pass_per = 100 * (float(successes) / total)
  376. pass_per = percent_to_html(pass_per)
  377. else:
  378. total = successes = pass_per = self.unknown_number
  379. bad_ones = failures + errors
  380. self.main_index.write(
  381. '<tr><td>{d}</td>'
  382. '<td><a href="{d}/{m}/index.html">{m}</a></td><td>{sf}</td>'
  383. '<td>{total}</td><td>{st}</td><td>{ft}</td><td>{pt}</td>'
  384. '<tr>'.format(
  385. d=module.tested_dir, m=module.name,
  386. sf=self.returncode_to_html_text(returncode),
  387. st=successes, ft=bad_ones, total=total, pt=pass_per))
  388. self.wrap_stdstream_to_html(infile=stdout,
  389. outfile=os.path.join(cwd, 'stdout.html'),
  390. module=module, stream='stdout')
  391. self.wrap_stdstream_to_html(infile=stderr,
  392. outfile=os.path.join(cwd, 'stderr.html'),
  393. module=module, stream='stderr')
  394. file_index_path = os.path.join(cwd, 'index.html')
  395. file_index = open(file_index_path, 'w')
  396. file_index.write(
  397. '<html><body>'
  398. '<h1>{m.name}</h1>'
  399. '<h2>{m.tested_dir} &ndash; {m.name}</h2>'
  400. '<p>{status}'
  401. '<p>Test duration: {dur}'
  402. '<ul>'
  403. '<li><a href="stdout.html">standard output (stdout)</a>'
  404. '<li><a href="stderr.html">standard error output (stderr)</a>'
  405. '<li><a href="testcodecoverage/index.html">code coverage</a>'
  406. '</ul>'
  407. '</body></html>'
  408. .format(
  409. dur=self.file_time, m=module,
  410. status=self.returncode_to_html_sentence(returncode),
  411. ))
  412. file_index.close()
  413. if returncode:
  414. pass
  415. # TODO: here we don't have oportunity to write error file
  416. # to stream (stdout/stderr)
  417. # a stream can be added and if not none, we could write
  418. class GrassTestFilesTextReporter(GrassTestFilesCountingReporter):
  419. def __init__(self, stream):
  420. super(GrassTestFilesTextReporter, self).__init__()
  421. self._stream = stream
  422. def start(self, results_dir):
  423. super(GrassTestFilesTextReporter, self).start(results_dir)
  424. def finish(self):
  425. super(GrassTestFilesTextReporter, self).finish()
  426. summary_sentence = ('\nExecuted {nfiles} test files in {time:}.'
  427. '\nFrom them'
  428. ' {nsfiles} files ({nsper:.0f}%) were successful'
  429. ' and {nffiles} files ({nfper:.0f}%) failed.\n'
  430. .format(
  431. nfiles=self.test_files,
  432. time=self.main_time,
  433. nsfiles=self.files_pass,
  434. nffiles=self.files_fail,
  435. nsper=self.file_pass_per,
  436. nfper=self.file_fail_per))
  437. self._stream.write(summary_sentence)
  438. def start_file_test(self, module):
  439. super(GrassTestFilesTextReporter, self).start_file_test(module)
  440. self._stream.flush() # to get previous lines to the report
  441. def end_file_test(self, module, cwd, returncode, stdout, stderr,
  442. test_summary):
  443. super(GrassTestFilesTextReporter, self).end_file_test(
  444. module=module, cwd=cwd, returncode=returncode,
  445. stdout=stdout, stderr=stderr)
  446. if returncode:
  447. self._stream.write(
  448. '{m} from {d} failed'
  449. .format(
  450. d=module.tested_dir,
  451. m=module.name))
  452. num_failed = test_summary.get('failures', 0)
  453. num_failed += test_summary.get('errors', 0)
  454. if num_failed:
  455. if num_failed > 1:
  456. text = ' ({f} tests failed)'
  457. else:
  458. text = ' ({f} test failed)'
  459. self._stream.write(text.format(f=num_failed))
  460. self._stream.write('\n')
  461. # TODO: here we lost the possibility to include also file name
  462. # of the appropriate report