reporters.py 45 KB

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