reporters.py 43 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141
  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. import StringIO
  16. import collections
  17. import types
  18. import re
  19. from .utils import ensure_dir
  20. from .checkers import text_to_keyvalue
  21. # TODO: change text_to_keyvalue to same sep as here
  22. # TODO: create keyvalue file and move it there together with things from checkers
  23. def keyvalue_to_text(keyvalue, sep='=', vsep='\n', isep=',',
  24. last_vertical=None):
  25. if not last_vertical:
  26. last_vertical = vsep == '\n'
  27. items = []
  28. for key, value in keyvalue.iteritems():
  29. # TODO: use isep for iterables other than strings
  30. if (not isinstance(value, types.StringTypes)
  31. and isinstance(value, collections.Iterable)):
  32. # TODO: this does not work for list of non-strings
  33. value = isep.join(value)
  34. items.append('{key}{sep}{value}'.format(
  35. key=key, sep=sep, value=value))
  36. text = vsep.join(items)
  37. if last_vertical:
  38. text = text + vsep
  39. return text
  40. def replace_in_file(file_path, pattern, repl):
  41. """
  42. :param repl: a repl paramter of ``re.sub()`` function
  43. """
  44. # using tmp file to store the replaced content
  45. tmp_file_path = file_path + '.tmp'
  46. old_file = open(file_path, 'r')
  47. new_file = open(tmp_file_path, 'w')
  48. for line in old_file:
  49. new_file.write(re.sub(pattern=pattern, string=line, repl=repl))
  50. new_file.close()
  51. old_file.close()
  52. # remove old file since it must not exist for rename/move
  53. os.remove(file_path)
  54. # replace old file by new file
  55. os.rename(tmp_file_path, file_path)
  56. class NoopFileAnonymizer(object):
  57. def anonymize(self, filenames):
  58. pass
  59. # TODO: why not remove GISDBASE by default?
  60. class FileAnonymizer(object):
  61. def __init__(self, paths_to_remove, remove_gisbase=True,
  62. remove_gisdbase=False):
  63. self._paths_to_remove = []
  64. if remove_gisbase:
  65. gisbase = os.environ['GISBASE']
  66. self._paths_to_remove.append(gisbase)
  67. if remove_gisdbase:
  68. # import only when really needed to avoid problems with
  69. # translations when environment is not set properly
  70. import grass.script as gscript
  71. gisdbase = gscript.gisenv()['GISDBASE']
  72. self._paths_to_remove.append(gisdbase)
  73. if paths_to_remove:
  74. self._paths_to_remove.extend(paths_to_remove)
  75. def anonymize(self, filenames):
  76. # besides GISBASE and test recursion start directory (which is
  77. # supposed to be source root directory or similar) we can also try
  78. # to remove user home directory and GISDBASE
  79. # we suppuse that we run in standard grass session
  80. # TODO: provide more effective implementation
  81. for path in self._paths_to_remove:
  82. for filename in filenames:
  83. path_end = r'[\\/]?'
  84. replace_in_file(filename, path + path_end, '')
  85. def get_source_url(path, revision, line=None):
  86. """
  87. :param path: directory or file path relative to remote repository root
  88. :param revision: SVN revision (should be a number)
  89. :param line: line in the file (should be None for directories)
  90. """
  91. tracurl = 'http://trac.osgeo.org/grass/browser/'
  92. if line:
  93. return '{tracurl}{path}?rev={revision}#L{line}'.format(**locals())
  94. else:
  95. return '{tracurl}{path}?rev={revision}'.format(**locals())
  96. def html_escape(text):
  97. """Escape ``'&'``, ``'<'``, and ``'>'`` in a string of data."""
  98. return saxutils.escape(text)
  99. def html_unescape(text):
  100. """Unescape ``'&amp;'``, ``'&lt;'``, and ``'&gt;'`` in a string of data."""
  101. return saxutils.unescape(text)
  102. def color_error_line(line):
  103. if line.startswith('ERROR: '):
  104. # TODO: use CSS class
  105. # ignoring the issue with \n at the end, HTML don't mind
  106. line = '<span style="color: red">' + line + "</span>"
  107. if line.startswith('FAIL: '):
  108. # TODO: use CSS class
  109. # ignoring the issue with \n at the end, HTML don't mind
  110. line = '<span style="color: red">' + line + "</span>"
  111. if line.startswith('WARNING: '):
  112. # TODO: use CSS class
  113. # ignoring the issue with \n at the end, HTML don't mind
  114. line = '<span style="color: blue">' + line + "</span>"
  115. #if line.startswith('Traceback ('):
  116. # line = '<span style="color: red">' + line + "</span>"
  117. return line
  118. def get_svn_revision():
  119. """Get SVN revision number
  120. :returns: SVN revision number as string or None if it is
  121. not possible to get
  122. """
  123. # TODO: here should be starting directory
  124. # but now we are using current as starting
  125. p = subprocess.Popen(['svnversion', '.'],
  126. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  127. stdout, stderr = p.communicate()
  128. rc = p.poll()
  129. if not rc:
  130. stdout = stdout.strip()
  131. if stdout.endswith('M'):
  132. stdout = stdout[:-1]
  133. if ':' in stdout:
  134. # the first one is the one of source code
  135. stdout = stdout.split(':')[0]
  136. return stdout
  137. else:
  138. return None
  139. def get_svn_info():
  140. """Get important information from ``svn info``
  141. :returns: SVN info as dictionary or None
  142. if it is not possible to obtain it
  143. """
  144. try:
  145. # TODO: introduce directory, not only current
  146. p = subprocess.Popen(['svn', 'info', '.', '--xml'],
  147. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  148. stdout, stderr = p.communicate()
  149. rc = p.poll()
  150. info = {}
  151. if not rc:
  152. root = et.fromstring(stdout)
  153. # TODO: get also date if this make sense
  154. # expecting only one <entry> element
  155. entry = root.find('entry')
  156. info['revision'] = entry.get('revision')
  157. info['url'] = entry.find('url').text
  158. relurl = entry.find('relative-url')
  159. # element which is not found is None
  160. # empty element would be bool(el) == False
  161. if relurl is not None:
  162. relurl = relurl.text
  163. # relative path has ^ at the beginning in SVN version 1.8.8
  164. if relurl.startswith('^'):
  165. relurl = relurl[1:]
  166. else:
  167. # SVN version 1.8.8 supports relative-url but older do not
  168. # so, get relative part from absolute URL
  169. const_url_part = 'https://svn.osgeo.org/grass/'
  170. relurl = info['url'][len(const_url_part):]
  171. info['relative-url'] = relurl
  172. return info
  173. # TODO: add this to svnversion function
  174. except OSError as e:
  175. import errno
  176. # ignore No such file or directory
  177. if e.errno != errno.ENOENT:
  178. raise
  179. return None
  180. def years_ago(date, years):
  181. # dateutil relative delte would be better but this is more portable
  182. return date - datetime.timedelta(weeks=years * 52)
  183. # TODO: these functions should be called only if we know that svn is installed
  184. # this will simplify the functions, caller must handle it anyway
  185. def get_svn_path_authors(path, from_date=None):
  186. """
  187. :returns: a set of authors
  188. """
  189. if from_date is None:
  190. # this is the SVN default for local copies
  191. revision_range = 'BASE:1'
  192. else:
  193. revision_range = 'BASE:{%s}' % from_date
  194. try:
  195. # TODO: allow also usage of --limit
  196. p = subprocess.Popen(['svn', 'log', '--xml',
  197. '--revision', revision_range, path],
  198. stdout=subprocess.PIPE, stderr=subprocess.PIPE)
  199. stdout, stderr = p.communicate()
  200. rc = p.poll()
  201. if not rc:
  202. root = et.fromstring(stdout)
  203. # TODO: get also date if this make sense
  204. # expecting only one <entry> element
  205. author_nodes = root.iterfind('*/author')
  206. authors = [n.text for n in author_nodes]
  207. return set(authors)
  208. except OSError as e:
  209. import errno
  210. # ignore No such file or directory
  211. if e.errno != errno.ENOENT:
  212. raise
  213. return None
  214. def get_html_test_authors_table(directory, tests_authors):
  215. # SVN gives us authors of code together with authors of tests
  216. # so test code authors list also contains authors of tests only
  217. # TODO: don't do this for the top level directories?
  218. tests_authors = set(tests_authors)
  219. no_svn_text = ('<span style="font-size: 60%">'
  220. 'Test file authors were not obtained.'
  221. '</span>')
  222. if (not tests_authors
  223. or (len(tests_authors) == 1 and list(tests_authors)[0] == '')):
  224. return '<h3>Code and test authors</h3>' + no_svn_text
  225. from_date = years_ago(datetime.date.today(), years=1)
  226. tested_dir_authors = get_svn_path_authors(directory, from_date)
  227. if tested_dir_authors is not None:
  228. not_testing_authors = tested_dir_authors - tests_authors
  229. else:
  230. no_svn_text = ('<span style="font-size: 60%">'
  231. 'Authors cannot be obtained using SVN.'
  232. '</span>')
  233. not_testing_authors = tested_dir_authors = [no_svn_text]
  234. if not not_testing_authors:
  235. not_testing_authors = ['all recent authors contributed tests']
  236. test_authors = (
  237. '<h3>Code and test authors</h3>'
  238. '<p style="font-size: 60%"><em>'
  239. 'Note that determination of authors is approximate and only'
  240. ' recent code authors are considered.'
  241. '</em></p>'
  242. '<table><tbody>'
  243. '<tr><td>Test authors:</td><td>{file_authors}</td></tr>'
  244. '<tr><td>Authors of tested code:</td><td>{code_authors}</td></tr>'
  245. '<tr><td>Authors owing tests:</td><td>{not_testing}</td></tr>'
  246. '</tbody></table>'
  247. .format(
  248. file_authors=', '.join(sorted(tests_authors)),
  249. code_authors=', '.join(sorted(tested_dir_authors)),
  250. not_testing=', '.join(sorted(not_testing_authors))
  251. ))
  252. return test_authors
  253. class GrassTestFilesMultiReporter(object):
  254. def __init__(self, reporters, forgiving=False):
  255. self.reporters = reporters
  256. self.forgiving = forgiving
  257. def start(self, results_dir):
  258. # TODO: no directory cleaning (self.clean_before)? now cleaned by caller
  259. # TODO: perhaps only those whoe need it should do it (even multiple times)
  260. # and there is also the delet problem
  261. ensure_dir(os.path.abspath(results_dir))
  262. for reporter in self.reporters:
  263. try:
  264. reporter.start(results_dir)
  265. except AttributeError:
  266. if self.forgiving:
  267. pass
  268. else:
  269. raise
  270. def finish(self):
  271. for reporter in self.reporters:
  272. try:
  273. reporter.finish()
  274. except AttributeError:
  275. if self.forgiving:
  276. pass
  277. else:
  278. raise
  279. def start_file_test(self, module):
  280. for reporter in self.reporters:
  281. try:
  282. reporter.start_file_test(module)
  283. except AttributeError:
  284. if self.forgiving:
  285. pass
  286. else:
  287. raise
  288. def end_file_test(self, **kwargs):
  289. for reporter in self.reporters:
  290. try:
  291. reporter.end_file_test(**kwargs)
  292. except AttributeError:
  293. if self.forgiving:
  294. pass
  295. else:
  296. raise
  297. class GrassTestFilesCountingReporter(object):
  298. def __init__(self):
  299. self.test_files = None
  300. self.files_fail = None
  301. self.files_pass = None
  302. self.file_pass_per = None
  303. self.file_fail_per = None
  304. self.main_start_time = None
  305. self.main_end_time = None
  306. self.main_time = None
  307. self.file_start_time = None
  308. self.file_end_time = None
  309. self.file_time = None
  310. self._start_file_test_called = False
  311. def start(self, results_dir):
  312. self.test_files = 0
  313. self.files_fail = 0
  314. self.files_pass = 0
  315. # this might be moved to some report start method
  316. self.main_start_time = datetime.datetime.now()
  317. def finish(self):
  318. self.main_end_time = datetime.datetime.now()
  319. self.main_time = self.main_end_time - self.main_start_time
  320. assert self.test_files == self.files_fail + self.files_pass
  321. self.file_pass_per = 100 * float(self.files_pass) / self.test_files
  322. self.file_fail_per = 100 * float(self.files_fail) / self.test_files
  323. def start_file_test(self, module):
  324. self.file_start_time = datetime.datetime.now()
  325. self._start_file_test_called = True
  326. self.test_files += 1
  327. def end_file_test(self, returncode, **kwargs):
  328. assert self._start_file_test_called
  329. self.file_end_time = datetime.datetime.now()
  330. self.file_time = self.file_end_time - self.file_start_time
  331. if returncode:
  332. self.files_fail += 1
  333. else:
  334. self.files_pass += 1
  335. self._start_file_test_called = False
  336. def percent_to_html(percent):
  337. if percent > 100 or percent < 0:
  338. return "? {:.2f}% ?".format(percent)
  339. elif percent < 40:
  340. color = 'red'
  341. elif percent < 70:
  342. color = 'orange'
  343. else:
  344. color = 'green'
  345. return '<span style="color: {color}">{percent:.0f}%</span>'.format(
  346. percent=percent, color=color)
  347. def wrap_stdstream_to_html(infile, outfile, module, stream):
  348. before = '<html><body><h1>%s</h1><pre>' % (module.name + ' ' + stream)
  349. after = '</pre></body></html>'
  350. html = open(outfile, 'w')
  351. html.write(before)
  352. with open(infile) as text:
  353. for line in text:
  354. html.write(color_error_line(html_escape(line)))
  355. html.write(after)
  356. html.close()
  357. def html_file_preview(filename):
  358. before = '<pre>'
  359. after = '</pre>'
  360. if not os.path.isfile(filename):
  361. return '<p style="color: red>File %s does not exist<p>' % filename
  362. size = os.path.getsize(filename)
  363. if not size:
  364. return '<p style="color: red>File %s is empty<p>' % filename
  365. max_size = 10000
  366. html = StringIO.StringIO()
  367. html.write(before)
  368. if size < max_size:
  369. with open(filename) as text:
  370. for line in text:
  371. html.write(color_error_line(html_escape(line)))
  372. elif size < 10 * max_size:
  373. def tail(filename, n):
  374. return collections.deque(open(filename), n)
  375. html.write('... (lines omitted)\n')
  376. for line in tail(filename, 50):
  377. html.write(color_error_line(html_escape(line)))
  378. else:
  379. return '<p style="color: red>File %s is too large to show<p>' % filename
  380. html.write(after)
  381. return html.getvalue()
  382. def returncode_to_html_text(returncode):
  383. if returncode:
  384. return '<span style="color: red">FAILED</span>'
  385. else:
  386. # alternatives: SUCCEEDED, passed, OK
  387. return '<span style="color: green">succeeded</span>'
  388. # not used
  389. def returncode_to_html_sentence(returncode):
  390. if returncode:
  391. return ('<span style="color: red">&#x274c;</span>'
  392. ' Test failed (return code %d)' % (returncode))
  393. else:
  394. return ('<span style="color: green">&#x2713;</span>'
  395. ' Test succeeded (return code %d)' % (returncode))
  396. def returncode_to_success_html_par(returncode):
  397. if returncode:
  398. return ('<p> <span style="color: red">&#x274c;</span>'
  399. ' Test failed</p>')
  400. else:
  401. return ('<p> <span style="color: green">&#x2713;</span>'
  402. ' Test succeeded</p>')
  403. def success_to_html_text(total, successes):
  404. if successes < total:
  405. return '<span style="color: red">FAILED</span>'
  406. elif successes == total:
  407. # alternatives: SUCCEEDED, passed, OK
  408. return '<span style="color: green">succeeded</span>'
  409. else:
  410. return ('<span style="color: red; font-size: 60%">'
  411. '? more successes than total ?</span>')
  412. UNKNOWN_NUMBER_HTML = '<span style="font-size: 60%">unknown</span>'
  413. def success_to_html_percent(total, successes):
  414. if total:
  415. pass_per = 100 * (float(successes) / total)
  416. pass_per = percent_to_html(pass_per)
  417. else:
  418. pass_per = UNKNOWN_NUMBER_HTML
  419. return pass_per
  420. class GrassTestFilesHtmlReporter(GrassTestFilesCountingReporter):
  421. unknown_number = UNKNOWN_NUMBER_HTML
  422. def __init__(self, file_anonymizer, main_page_name='index.html'):
  423. super(GrassTestFilesHtmlReporter, self).__init__()
  424. self.main_index = None
  425. self._file_anonymizer = file_anonymizer
  426. self._main_page_name = main_page_name
  427. def start(self, results_dir):
  428. super(GrassTestFilesHtmlReporter, self).start(results_dir)
  429. # having all variables public although not really part of API
  430. main_page_name = os.path.join(results_dir, self._main_page_name)
  431. self.main_index = open(main_page_name, 'w')
  432. # TODO: this can be moved to the counter class
  433. self.failures = 0
  434. self.errors = 0
  435. self.skipped = 0
  436. self.successes = 0
  437. self.expected_failures = 0
  438. self.unexpected_success = 0
  439. self.total = 0
  440. svn_info = get_svn_info()
  441. if not svn_info:
  442. svn_text = ('<span style="font-size: 60%">'
  443. 'SVN revision cannot be obtained'
  444. '</span>')
  445. else:
  446. url = get_source_url(path=svn_info['relative-url'],
  447. revision=svn_info['revision'])
  448. svn_text = ('SVN revision'
  449. ' <a href="{url}">'
  450. '{rev}</a>'
  451. ).format(url=url, rev=svn_info['revision'])
  452. self.main_index.write('<html><body>'
  453. '<h1>Test results</h1>'
  454. '{time:%Y-%m-%d %H:%M:%S}'
  455. ' ({svn})'
  456. '<table>'
  457. '<thead><tr>'
  458. '<th>Tested directory</th>'
  459. '<th>Test file</th>'
  460. '<th>Status</th>'
  461. '<th>Tests</th><th>Successful</td>'
  462. '<th>Failed</th><th>Percent successful</th>'
  463. '</tr></thead><tbody>'.format(
  464. time=self.main_start_time,
  465. svn=svn_text))
  466. def finish(self):
  467. super(GrassTestFilesHtmlReporter, self).finish()
  468. pass_per = success_to_html_percent(total=self.total,
  469. successes=self.successes)
  470. tfoot = ('<tfoot>'
  471. '<tr>'
  472. '<td>Summary</td>'
  473. '<td>{nfiles} test files</td>'
  474. '<td>{nsper}</td>'
  475. '<td>{total}</td><td>{st}</td><td>{ft}</td><td>{pt}</td>'
  476. '</tr>'
  477. '</tfoot>'.format(
  478. nfiles=self.test_files,
  479. nsper=percent_to_html(self.file_pass_per),
  480. st=self.successes, ft=self.failures + self.errors,
  481. total=self.total, pt=pass_per
  482. ))
  483. summary_sentence = ('Executed {nfiles} test files in {time:}.'
  484. ' From them'
  485. ' {nsfiles} files ({nsper:.0f}%) were successful'
  486. ' and {nffiles} files ({nfper:.0f}%) failed.'
  487. .format(
  488. nfiles=self.test_files,
  489. time=self.main_time,
  490. nsfiles=self.files_pass,
  491. nffiles=self.files_fail,
  492. nsper=self.file_pass_per,
  493. nfper=self.file_fail_per))
  494. self.main_index.write('<tbody>{tfoot}</table>'
  495. '<p>{summary}</p>'
  496. '</body></html>'
  497. .format(
  498. tfoot=tfoot,
  499. summary=summary_sentence))
  500. self.main_index.close()
  501. def start_file_test(self, module):
  502. super(GrassTestFilesHtmlReporter, self).start_file_test(module)
  503. self.main_index.flush() # to get previous lines to the report
  504. def end_file_test(self, module, cwd, returncode, stdout, stderr,
  505. test_summary):
  506. super(GrassTestFilesHtmlReporter, self).end_file_test(
  507. module=module, cwd=cwd, returncode=returncode,
  508. stdout=stdout, stderr=stderr)
  509. # considering others accoring to total is OK when we more or less
  510. # know that input data make sense (total >= errors + failures)
  511. total = test_summary.get('total', None)
  512. failures = test_summary.get('failures', 0)
  513. errors = test_summary.get('errors', 0)
  514. # Python unittest TestResult class is reporting success for no
  515. # errors or failures, so skipped, expected failures and unexpected
  516. # success are ignored
  517. # but successful tests are only total - the others
  518. # TODO: add success counter to GrassTestResult base class
  519. skipped = test_summary.get('skipped', 0)
  520. expected_failures = test_summary.get('expected_failures', 0)
  521. unexpected_successes = test_summary.get('unexpected_successes', 0)
  522. successes = test_summary.get('successes', 0)
  523. self.failures += failures
  524. self.errors += errors
  525. self.skipped += skipped
  526. self.expected_failures += expected_failures
  527. self.unexpected_success += unexpected_successes
  528. # zero would be valid here
  529. if total is not None:
  530. # success are only the clear ones
  531. # percentage is influenced by all
  532. # but putting only failures to table
  533. self.successes += successes
  534. self.total += total
  535. # this will handle zero
  536. pass_per = success_to_html_percent(total=total,
  537. successes=successes)
  538. else:
  539. total = successes = pass_per = self.unknown_number
  540. bad_ones = failures + errors
  541. self.main_index.write(
  542. '<tr><td>{d}</td>'
  543. '<td><a href="{d}/{m}/index.html">{m}</a></td>'
  544. '<td>{status}</td>'
  545. '<td>{ntests}</td><td>{stests}</td>'
  546. '<td>{ftests}</td><td>{ptests}</td>'
  547. '<tr>'.format(
  548. d=module.tested_dir, m=module.name,
  549. status=returncode_to_html_text(returncode),
  550. stests=successes, ftests=bad_ones, ntests=total,
  551. ptests=pass_per))
  552. wrap_stdstream_to_html(infile=stdout,
  553. outfile=os.path.join(cwd, 'stdout.html'),
  554. module=module, stream='stdout')
  555. wrap_stdstream_to_html(infile=stderr,
  556. outfile=os.path.join(cwd, 'stderr.html'),
  557. module=module, stream='stderr')
  558. file_index_path = os.path.join(cwd, 'index.html')
  559. file_index = open(file_index_path, 'w')
  560. file_index.write(
  561. '<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>'
  562. '<h1>{m.name}</h1>'
  563. '<h2>{m.tested_dir} &ndash; {m.name}</h2>'
  564. '{status}'
  565. .format(
  566. m=module,
  567. status=returncode_to_success_html_par(returncode),
  568. ))
  569. # TODO: include optionaly link to test suite
  570. summary_section = (
  571. '<table><tbody>'
  572. '<tr><td>Test file</td><td>{m}</td></tr>'
  573. '<tr><td>Testsuite</td><td>{d}</td></tr>'
  574. '<tr><td>Status</td><td>{status}</td></tr>'
  575. '<tr><td>Return code</td><td>{rc}</td></tr>'
  576. '<tr><td>Number of tests</td><td>{ntests}</td></tr>'
  577. '<tr><td>Successful tests</td><td>{stests}</td></tr>'
  578. '<tr><td>Failed tests</td><td>{ftests}</td></tr>'
  579. '<tr><td>Percent successful</td><td>{ptests}</td></tr>'
  580. '<tr><td>Test duration</td><td>{dur}</td></tr>'
  581. .format(
  582. d=module.tested_dir, m=module.name,
  583. status=returncode_to_html_text(returncode),
  584. stests=successes, ftests=bad_ones, ntests=total,
  585. ptests=pass_per, rc=returncode,
  586. dur=self.file_time))
  587. file_index.write(summary_section)
  588. modules = test_summary.get('tested_modules', None)
  589. if modules:
  590. # TODO: replace by better handling of potential lists when parsing
  591. # TODO: create link to module if running in grass or in addons
  592. # alternatively a link to module test summary
  593. if type(modules) is not list:
  594. modules = [modules]
  595. file_index.write(
  596. '<tr><td>Tested modules</td><td>{}</td></tr>'.format(
  597. ', '.join(sorted(set(modules)))))
  598. file_index.write('<tbody><table>')
  599. # here we would have also links to coverage, profiling, ...
  600. #'<li><a href="testcodecoverage/index.html">code coverage</a></li>'
  601. files_section = (
  602. '<h3>Supplementary files</h3>'
  603. '<ul>'
  604. '<li><a href="stdout.html">standard output (stdout)</a></li>'
  605. '<li><a href="stderr.html">standard error output (stderr)</a></li>'
  606. )
  607. file_index.write(files_section)
  608. supplementary_files = test_summary.get('supplementary_files', None)
  609. if supplementary_files:
  610. # this is something we might want to do once for all and not
  611. # risk that it will be done twice or rely that somebody else
  612. # will do it for use
  613. # the solution is perhaps do the multi reporter more grass-specific
  614. # and do all common things, so that other can rely on it and
  615. # moreover something can be shared with other explicity
  616. # using constructors as seems advantageous for counting
  617. self._file_anonymizer.anonymize(supplementary_files)
  618. for f in supplementary_files:
  619. file_index.write('<li><a href="{f}">{f}</a></li>'.format(f=f))
  620. file_index.write('</ul>')
  621. if returncode:
  622. file_index.write('<h3>Standard error output (stderr)</h3>')
  623. file_index.write(html_file_preview(stderr))
  624. file_index.write('</body></html>')
  625. file_index.close()
  626. if returncode:
  627. pass
  628. # TODO: here we don't have oportunity to write error file
  629. # to stream (stdout/stderr)
  630. # a stream can be added and if not none, we could write
  631. # TODO: document info: additional information to be stored type: dict
  632. # allows to overwrite what was collected
  633. class GrassTestFilesKeyValueReporter(GrassTestFilesCountingReporter):
  634. def __init__(self, info=None):
  635. super(GrassTestFilesKeyValueReporter, self).__init__()
  636. self.result_dir = None
  637. self._info = info
  638. def start(self, results_dir):
  639. super(GrassTestFilesKeyValueReporter, self).start(results_dir)
  640. # having all variables public although not really part of API
  641. self.result_dir = results_dir
  642. # TODO: this can be moved to the counter class
  643. self.failures = 0
  644. self.errors = 0
  645. self.skipped = 0
  646. self.successes = 0
  647. self.expected_failures = 0
  648. self.unexpected_success = 0
  649. self.total = 0
  650. # TODO: document: tested_dirs is a list and it should fit with names
  651. self.names = []
  652. self.tested_dirs = []
  653. self.files_returncodes = []
  654. # sets (no size specified)
  655. self.modules = set()
  656. self.test_files_authors = set()
  657. def finish(self):
  658. super(GrassTestFilesKeyValueReporter, self).finish()
  659. # this shoul be moved to some additional meta passed in constructor
  660. svn_info = get_svn_info()
  661. if not svn_info:
  662. svn_revision = ''
  663. else:
  664. svn_revision = svn_info['revision']
  665. summary = {}
  666. summary['files_total'] = self.test_files
  667. summary['files_successes'] = self.files_pass
  668. summary['files_failures'] = self.files_fail
  669. summary['names'] = self.names
  670. summary['tested_dirs'] = self.tested_dirs
  671. # TODO: we don't have a general mechanism for storing any type in text
  672. summary['files_returncodes'] = [str(item)
  673. for item in self.files_returncodes]
  674. # let's use seconds as a universal time delta format
  675. # (there is no standard way how to store time delta as string)
  676. summary['time'] = self.main_time.total_seconds()
  677. status = 'failed' if self.files_fail else 'succeeded'
  678. summary['status'] = status
  679. summary['total'] = self.total
  680. summary['successes'] = self.successes
  681. summary['failures'] = self.failures
  682. summary['errors'] = self.errors
  683. summary['skipped'] = self.skipped
  684. summary['expected_failures'] = self.expected_failures
  685. summary['unexpected_successes'] = self.unexpected_success
  686. summary['test_files_authors'] = self.test_files_authors
  687. summary['tested_modules'] = self.modules
  688. summary['svn_revision'] = svn_revision
  689. # ignoring issues with time zones
  690. summary['timestamp'] = self.main_start_time.strftime('%Y-%m-%d %H:%M:%S')
  691. # TODO: add some general metadata here (passed in constructor)
  692. # add additional information
  693. for key, value in self._info.iteritems():
  694. summary[key] = value
  695. summary_filename = os.path.join(self.result_dir,
  696. 'test_keyvalue_result.txt')
  697. with open(summary_filename, 'w') as summary_file:
  698. text = keyvalue_to_text(summary, sep='=', vsep='\n', isep=',')
  699. summary_file.write(text)
  700. def end_file_test(self, module, cwd, returncode, stdout, stderr,
  701. test_summary):
  702. super(GrassTestFilesKeyValueReporter, self).end_file_test(
  703. module=module, cwd=cwd, returncode=returncode,
  704. stdout=stdout, stderr=stderr)
  705. # TODO: considering others accoring to total, OK?
  706. # here we are using 0 for total but HTML reporter is using None
  707. total = test_summary.get('total', 0)
  708. failures = test_summary.get('failures', 0)
  709. errors = test_summary.get('errors', 0)
  710. # Python unittest TestResult class is reporting success for no
  711. # errors or failures, so skipped, expected failures and unexpected
  712. # success are ignored
  713. # but successful tests are only total - the others
  714. skipped = test_summary.get('skipped', 0)
  715. expected_failures = test_summary.get('expected_failures', 0)
  716. unexpected_successes = test_summary.get('unexpected_successes', 0)
  717. successes = test_summary.get('successes', 0)
  718. # TODO: move this to counter class and perhaps use aggregation
  719. # rather then inheritance
  720. self.failures += failures
  721. self.errors += errors
  722. self.skipped += skipped
  723. self.expected_failures += expected_failures
  724. self.unexpected_success += unexpected_successes
  725. # TODO: should we test for zero?
  726. if total is not None:
  727. # success are only the clear ones
  728. # percentage is influenced by all
  729. # but putting only failures to table
  730. self.successes += successes
  731. self.total += total
  732. self.files_returncodes.append(returncode)
  733. self.tested_dirs.append(module.tested_dir)
  734. self.names.append(module.name)
  735. modules = test_summary.get('tested_modules', None)
  736. if modules:
  737. # TODO: replace by better handling of potential lists when parsing
  738. # TODO: create link to module if running in grass or in addons
  739. # alternatively a link to module test summary
  740. if type(modules) not in [list, set]:
  741. modules = [modules]
  742. self.modules.update(modules)
  743. test_file_authors = test_summary['test_file_authors']
  744. if type(test_file_authors) not in [list, set]:
  745. test_file_authors = [test_file_authors]
  746. self.test_files_authors.update(test_file_authors)
  747. class GrassTestFilesTextReporter(GrassTestFilesCountingReporter):
  748. def __init__(self, stream):
  749. super(GrassTestFilesTextReporter, self).__init__()
  750. self._stream = stream
  751. def start(self, results_dir):
  752. super(GrassTestFilesTextReporter, self).start(results_dir)
  753. def finish(self):
  754. super(GrassTestFilesTextReporter, self).finish()
  755. summary_sentence = ('\nExecuted {nfiles} test files in {time:}.'
  756. '\nFrom them'
  757. ' {nsfiles} files ({nsper:.0f}%) were successful'
  758. ' and {nffiles} files ({nfper:.0f}%) failed.\n'
  759. .format(
  760. nfiles=self.test_files,
  761. time=self.main_time,
  762. nsfiles=self.files_pass,
  763. nffiles=self.files_fail,
  764. nsper=self.file_pass_per,
  765. nfper=self.file_fail_per))
  766. self._stream.write(summary_sentence)
  767. def start_file_test(self, module):
  768. super(GrassTestFilesTextReporter, self).start_file_test(module)
  769. self._stream.flush() # to get previous lines to the report
  770. def end_file_test(self, module, cwd, returncode, stdout, stderr,
  771. test_summary):
  772. super(GrassTestFilesTextReporter, self).end_file_test(
  773. module=module, cwd=cwd, returncode=returncode,
  774. stdout=stdout, stderr=stderr)
  775. if returncode:
  776. self._stream.write(
  777. '{m} from {d} failed'
  778. .format(
  779. d=module.tested_dir,
  780. m=module.name))
  781. num_failed = test_summary.get('failures', 0)
  782. num_failed += test_summary.get('errors', 0)
  783. if num_failed:
  784. if num_failed > 1:
  785. text = ' ({f} tests failed)'
  786. else:
  787. text = ' ({f} test failed)'
  788. self._stream.write(text.format(f=num_failed))
  789. self._stream.write('\n')
  790. # TODO: here we lost the possibility to include also file name
  791. # of the appropriate report
  792. # TODO: there is a quite a lot duplication between this class and html reporter
  793. # TODO: document: do not use it for two reports, it accumulates the results
  794. # TODO: add also keyvalue summary generation?
  795. # wouldn't this conflict with collecting data from report afterwards?
  796. class TestsuiteDirReporter(object):
  797. def __init__(self, main_page_name, testsuite_page_name='index.html',
  798. top_level_testsuite_page_name=None):
  799. self.main_page_name = main_page_name
  800. self.testsuite_page_name = testsuite_page_name
  801. self.top_level_testsuite_page_name = top_level_testsuite_page_name
  802. # TODO: this might be even a object which could add and validate
  803. self.failures = 0
  804. self.errors = 0
  805. self.skipped = 0
  806. self.successes = 0
  807. self.expected_failures = 0
  808. self.unexpected_successes = 0
  809. self.total = 0
  810. self.testsuites = 0
  811. self.testsuites_successes = 0
  812. self.files = 0
  813. self.files_successes = 0
  814. def report_for_dir(self, root, directory, test_files):
  815. # TODO: create object from this, so that it can be passed from
  816. # one function to another
  817. # TODO: put the inside of for loop to another fucntion
  818. dir_failures = 0
  819. dir_errors = 0
  820. dir_skipped = 0
  821. dir_successes = 0
  822. dir_expected_failures = 0
  823. dir_unexpected_success = 0
  824. dir_total = 0
  825. test_files_authors = []
  826. file_total = 0
  827. file_successes = 0
  828. page_name = os.path.join(root, directory, self.testsuite_page_name)
  829. if (self.top_level_testsuite_page_name and
  830. os.path.abspath(os.path.join(root, directory))
  831. == os.path.abspath(root)):
  832. page_name = os.path.join(root, self.top_level_testsuite_page_name)
  833. page = open(page_name, 'w')
  834. head = (
  835. '<html><body>'
  836. '<h1>{name} testsuite results</h1>'
  837. .format(name=directory))
  838. tests_table_head = (
  839. '<h3>Test files results</h3>'
  840. '<table>'
  841. '<thead><tr>'
  842. '<th>Test file</th><th>Status</th>'
  843. '<th>Tests</th><th>Successful</td>'
  844. '<th>Failed</th><th>Percent successful</th>'
  845. '</tr></thead><tbody>'
  846. )
  847. page.write(head)
  848. page.write(tests_table_head)
  849. for test_file_name in test_files:
  850. # TODO: put keyvalue fine name to constant
  851. summary_filename = os.path.join(root, directory, test_file_name,
  852. 'test_keyvalue_result.txt')
  853. #if os.path.exists(summary_filename):
  854. with open(summary_filename, 'r') as keyval_file:
  855. summary = text_to_keyvalue(keyval_file.read(), sep='=')
  856. #else:
  857. # TODO: write else here
  858. # summary = None
  859. if 'total' not in summary:
  860. bad_ones = successes = UNKNOWN_NUMBER_HTML
  861. total = None
  862. else:
  863. bad_ones = summary['failures'] + summary['errors']
  864. successes = summary['successes']
  865. total = summary['total']
  866. self.failures += summary['failures']
  867. self.errors += summary['errors']
  868. self.skipped += summary['skipped']
  869. self.successes += summary['successes']
  870. self.expected_failures += summary['expected_failures']
  871. self.unexpected_successes += summary['unexpected_successes']
  872. self.total += summary['total']
  873. dir_failures += summary['failures']
  874. dir_errors += summary['failures']
  875. dir_skipped += summary['skipped']
  876. dir_successes += summary['successes']
  877. dir_expected_failures += summary['expected_failures']
  878. dir_unexpected_success += summary['unexpected_successes']
  879. dir_total += summary['total']
  880. # TODO: keyvalue method should have types for keys function
  881. # perhaps just the current post processing function is enough
  882. test_file_authors = summary['test_file_authors']
  883. if type(test_file_authors) is not list:
  884. test_file_authors = [test_file_authors]
  885. test_files_authors.extend(test_file_authors)
  886. file_total += 1
  887. file_successes += 0 if summary['returncode'] else 1
  888. pass_per = success_to_html_percent(total=total,
  889. successes=successes)
  890. row = (
  891. '<tr>'
  892. '<td><a href="{f}/index.html">{f}</a></td>'
  893. '<td>{status}</td>'
  894. '<td>{ntests}</td><td>{stests}</td>'
  895. '<td>{ftests}</td><td>{ptests}</td>'
  896. '<tr>'
  897. .format(
  898. f=test_file_name,
  899. status=returncode_to_html_text(summary['returncode']),
  900. stests=successes, ftests=bad_ones, ntests=total,
  901. ptests=pass_per))
  902. page.write(row)
  903. self.testsuites += 1
  904. self.testsuites_successes += 1 if file_successes == file_total else 0
  905. self.files += file_total
  906. self.files_successes += file_successes
  907. dir_pass_per = success_to_html_percent(total=dir_total,
  908. successes=dir_successes)
  909. file_pass_per = success_to_html_percent(total=file_total,
  910. successes=file_successes)
  911. tests_table_foot = (
  912. '</tbody><tfoot><tr>'
  913. '<td>Summary</td>'
  914. '<td>{status}</td>'
  915. '<td>{ntests}</td><td>{stests}</td>'
  916. '<td>{ftests}</td><td>{ptests}</td>'
  917. '</tr></tfoot></table>'
  918. .format(
  919. status=file_pass_per,
  920. stests=dir_successes, ftests=dir_failures + dir_errors,
  921. ntests=dir_total, ptests=dir_pass_per))
  922. page.write(tests_table_foot)
  923. test_authors = get_html_test_authors_table(
  924. directory=directory, tests_authors=test_files_authors)
  925. page.write(test_authors)
  926. page.write('</body></html>')
  927. page.close()
  928. status = success_to_html_text(total=file_total, successes=file_successes)
  929. row = (
  930. '<tr>'
  931. '<td><a href="{d}/{page}">{d}</a></td><td>{status}</td>'
  932. '<td>{nfiles}</td><td>{sfiles}</td><td>{pfiles}</td>'
  933. '<td>{ntests}</td><td>{stests}</td>'
  934. '<td>{ftests}</td><td>{ptests}</td>'
  935. '<tr>'
  936. .format(
  937. d=directory, page=self.testsuite_page_name, status=status,
  938. nfiles=file_total, sfiles=file_successes, pfiles=file_pass_per,
  939. stests=dir_successes, ftests=dir_failures + dir_errors,
  940. ntests=dir_total, ptests=dir_pass_per))
  941. return row
  942. def report_for_dirs(self, root, directories):
  943. # TODO: this will need chanages accoring to potential chnages in absolute/relative paths
  944. page_name = os.path.join(root, self.main_page_name)
  945. page = open(page_name, 'w')
  946. head = (
  947. '<html><body>'
  948. '<h1>Testsuites results</h1>'
  949. )
  950. tests_table_head = (
  951. '<table>'
  952. '<thead><tr>'
  953. '<th>Testsuite</th>'
  954. '<th>Status</th>'
  955. '<th>Test files</th><th>Successful</td>'
  956. '<th>Percent successful</th>'
  957. '<th>Tests</th><th>Successful</td>'
  958. '<th>Failed</th><th>Percent successful</th>'
  959. '</tr></thead><tbody>'
  960. )
  961. page.write(head)
  962. page.write(tests_table_head)
  963. for directory, test_files in directories.iteritems():
  964. row = self.report_for_dir(root=root, directory=directory,
  965. test_files=test_files)
  966. page.write(row)
  967. pass_per = success_to_html_percent(total=self.total,
  968. successes=self.successes)
  969. file_pass_per = success_to_html_percent(total=self.files,
  970. successes=self.files_successes)
  971. testsuites_pass_per = success_to_html_percent(
  972. total=self.testsuites, successes=self.testsuites_successes)
  973. tests_table_foot = (
  974. '<tfoot>'
  975. '<tr>'
  976. '<td>Summary</td><td>{status}</td>'
  977. '<td>{nfiles}</td><td>{sfiles}</td><td>{pfiles}</td>'
  978. '<td>{ntests}</td><td>{stests}</td>'
  979. '<td>{ftests}</td><td>{ptests}</td>'
  980. '</tr>'
  981. '</tfoot>'
  982. .format(
  983. status=testsuites_pass_per, nfiles=self.files,
  984. sfiles=self.files_successes, pfiles=file_pass_per,
  985. stests=self.successes, ftests=self.failures + self.errors,
  986. ntests=self.total, ptests=pass_per))
  987. page.write(tests_table_foot)
  988. page.write('</body></html>')