reporters.py 45 KB

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