reporters.py 44 KB

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