reporters.py 42 KB

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