123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263 |
- """
- GRASS Python testing framework module for report generation
- Copyright (C) 2014 by the GRASS Development Team
- This program is free software under the GNU General Public
- License (>=v2). Read the file COPYING that comes with GRASS GIS
- for details.
- :authors: Vaclav Petras
- """
- import os
- import datetime
- import xml.sax.saxutils as saxutils
- import xml.etree.ElementTree as et
- import subprocess
- import sys
- import collections
- import re
- from collections.abc import Iterable
- from .utils import ensure_dir
- from .checkers import text_to_keyvalue
- if sys.version_info[0] == 2:
- from StringIO import StringIO
- else:
- from io import StringIO
- # TODO: change text_to_keyvalue to same sep as here
- # TODO: create keyvalue file and move it there together with things from checkers
- def keyvalue_to_text(keyvalue, sep="=", vsep="\n", isep=",", last_vertical=None):
- if not last_vertical:
- last_vertical = vsep == "\n"
- items = []
- for key, value in keyvalue.items():
- # TODO: use isep for iterables other than strings
- if not isinstance(value, str) and isinstance(value, Iterable):
- # TODO: this does not work for list of non-strings
- value = isep.join(value)
- items.append("{key}{sep}{value}".format(key=key, sep=sep, value=value))
- text = vsep.join(items)
- if last_vertical:
- text = text + vsep
- return text
- def replace_in_file(file_path, pattern, repl):
- """
- :param repl: a repl parameter of ``re.sub()`` function
- """
- # using tmp file to store the replaced content
- tmp_file_path = file_path + ".tmp"
- old_file = open(file_path, "r")
- new_file = open(tmp_file_path, "w")
- for line in old_file:
- new_file.write(re.sub(pattern=pattern, string=line, repl=repl))
- new_file.close()
- old_file.close()
- # remove old file since it must not exist for rename/move
- os.remove(file_path)
- # replace old file by new file
- # TODO: this can fail in some (random) cases on MS Windows
- os.rename(tmp_file_path, file_path)
- class NoopFileAnonymizer(object):
- def anonymize(self, filenames):
- pass
- # TODO: why not remove GISDBASE by default?
- class FileAnonymizer(object):
- def __init__(self, paths_to_remove, remove_gisbase=True, remove_gisdbase=False):
- self._paths_to_remove = []
- if remove_gisbase:
- gisbase = os.environ["GISBASE"]
- self._paths_to_remove.append(gisbase)
- if remove_gisdbase:
- # import only when really needed to avoid problems with
- # translations when environment is not set properly
- import grass.script as gscript
- gisdbase = gscript.gisenv()["GISDBASE"]
- self._paths_to_remove.append(gisdbase)
- if paths_to_remove:
- self._paths_to_remove.extend(paths_to_remove)
- def anonymize(self, filenames):
- # besides GISBASE and test recursion start directory (which is
- # supposed to be source root directory or similar) we can also try
- # to remove user home directory and GISDBASE
- # we suppose that we run in standard grass session
- # TODO: provide more effective implementation
- # regex for a trailing separator
- path_end = r"[\\/]?"
- for path in self._paths_to_remove:
- for filename in filenames:
- # literal special characters (e.g., ^\()[]{}.*+-$) in path need
- # to be escaped in a regex (2nd argument); otherwise, they will
- # be interpreted as special characters
- replace_in_file(filename, re.escape(path) + path_end, "")
- def get_source_url(path, revision, line=None):
- """
- :param path: directory or file path relative to remote repository root
- :param revision: SVN revision (should be a number)
- :param line: line in the file (should be None for directories)
- """
- tracurl = "http://trac.osgeo.org/grass/browser/"
- if line:
- return "{tracurl}{path}?rev={revision}#L{line}".format(**locals())
- else:
- return "{tracurl}{path}?rev={revision}".format(**locals())
- def html_escape(text):
- """Escape ``'&'``, ``'<'``, and ``'>'`` in a string of data."""
- return saxutils.escape(text)
- def html_unescape(text):
- """Unescape ``'&'``, ``'<'``, and ``'>'`` in a string of data."""
- return saxutils.unescape(text)
- def color_error_line(line):
- if line.startswith("ERROR: "):
- # TODO: use CSS class
- # ignoring the issue with \n at the end, HTML don't mind
- line = '<span style="color: red">' + line + "</span>"
- if line.startswith("FAIL: "):
- # TODO: use CSS class
- # ignoring the issue with \n at the end, HTML don't mind
- line = '<span style="color: red">' + line + "</span>"
- if line.startswith("WARNING: "):
- # TODO: use CSS class
- # ignoring the issue with \n at the end, HTML don't mind
- line = '<span style="color: blue">' + line + "</span>"
- # if line.startswith('Traceback ('):
- # line = '<span style="color: red">' + line + "</span>"
- return line
- def to_web_path(path):
- """Replace OS dependent path separator with slash.
- Path on MS Windows are not usable in links on web. For MS Windows,
- this replaces backslash with (forward) slash.
- """
- if os.path.sep != "/":
- return path.replace(os.path.sep, "/")
- else:
- return path
- def get_svn_revision():
- """Get SVN revision number
- :returns: SVN revision number as string or None if it is
- not possible to get
- """
- # TODO: here should be starting directory
- # but now we are using current as starting
- p = subprocess.Popen(
- ["svnversion", "."], stdout=subprocess.PIPE, stderr=subprocess.PIPE
- )
- stdout, stderr = p.communicate()
- rc = p.poll()
- if not rc:
- stdout = stdout.strip()
- if stdout.endswith("M"):
- stdout = stdout[:-1]
- if ":" in stdout:
- # the first one is the one of source code
- stdout = stdout.split(":")[0]
- return stdout
- else:
- return None
- def get_svn_info():
- """Get important information from ``svn info``
- :returns: SVN info as dictionary or None
- if it is not possible to obtain it
- """
- try:
- # TODO: introduce directory, not only current
- p = subprocess.Popen(
- ["svn", "info", ".", "--xml"],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- stdout, stderr = p.communicate()
- rc = p.poll()
- info = {}
- if not rc:
- root = et.fromstring(stdout)
- # TODO: get also date if this make sense
- # expecting only one <entry> element
- entry = root.find("entry")
- info["revision"] = entry.get("revision")
- info["url"] = entry.find("url").text
- relurl = entry.find("relative-url")
- # element which is not found is None
- # empty element would be bool(el) == False
- if relurl is not None:
- relurl = relurl.text
- # relative path has ^ at the beginning in SVN version 1.8.8
- if relurl.startswith("^"):
- relurl = relurl[1:]
- else:
- # SVN version 1.8.8 supports relative-url but older do not
- # so, get relative part from absolute URL
- const_url_part = "https://svn.osgeo.org/grass/"
- relurl = info["url"][len(const_url_part) :]
- info["relative-url"] = relurl
- return info
- # TODO: add this to svnversion function
- except OSError as e:
- import errno
- # ignore No such file or directory
- if e.errno != errno.ENOENT:
- raise
- return None
- def years_ago(date, years):
- # dateutil relative delte would be better but this is more portable
- return date - datetime.timedelta(weeks=years * 52)
- # TODO: these functions should be called only if we know that svn is installed
- # this will simplify the functions, caller must handle it anyway
- def get_svn_path_authors(path, from_date=None):
- """
- :returns: a set of authors
- """
- if from_date is None:
- # this is the SVN default for local copies
- revision_range = "BASE:1"
- else:
- revision_range = "BASE:{%s}" % from_date
- try:
- # TODO: allow also usage of --limit
- p = subprocess.Popen(
- ["svn", "log", "--xml", "--revision", revision_range, path],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- stdout, stderr = p.communicate()
- rc = p.poll()
- if not rc:
- root = et.fromstring(stdout)
- # TODO: get also date if this make sense
- # expecting only one <entry> element
- author_nodes = root.iterfind("*/author")
- authors = [n.text for n in author_nodes]
- return set(authors)
- except OSError as e:
- import errno
- # ignore No such file or directory
- if e.errno != errno.ENOENT:
- raise
- return None
- def get_html_test_authors_table(directory, tests_authors):
- # SVN gives us authors of code together with authors of tests
- # so test code authors list also contains authors of tests only
- # TODO: don't do this for the top level directories?
- tests_authors = set(tests_authors)
- no_svn_text = (
- '<span style="font-size: 60%">' "Test file authors were not obtained." "</span>"
- )
- if not tests_authors or (len(tests_authors) == 1 and list(tests_authors)[0] == ""):
- return "<h3>Code and test authors</h3>" + no_svn_text
- from_date = years_ago(datetime.date.today(), years=1)
- tested_dir_authors = get_svn_path_authors(directory, from_date)
- if tested_dir_authors is not None:
- not_testing_authors = tested_dir_authors - tests_authors
- else:
- no_svn_text = (
- '<span style="font-size: 60%">'
- "Authors cannot be obtained using SVN."
- "</span>"
- )
- not_testing_authors = tested_dir_authors = [no_svn_text]
- if not not_testing_authors:
- not_testing_authors = ["all recent authors contributed tests"]
- test_authors = (
- "<h3>Code and test authors</h3>"
- '<p style="font-size: 60%"><em>'
- "Note that determination of authors is approximate and only"
- " recent code authors are considered."
- "</em></p>"
- "<table><tbody>"
- "<tr><td>Test authors:</td><td>{file_authors}</td></tr>"
- "<tr><td>Authors of tested code:</td><td>{code_authors}</td></tr>"
- "<tr><td>Authors owing tests:</td><td>{not_testing}</td></tr>"
- "</tbody></table>".format(
- file_authors=", ".join(sorted(tests_authors)),
- code_authors=", ".join(sorted(tested_dir_authors)),
- not_testing=", ".join(sorted(not_testing_authors)),
- )
- )
- return test_authors
- class GrassTestFilesMultiReporter(object):
- """Interface to multiple repoter objects
- For start and finish of the tests and of a test of one file,
- it calls corresponding methods of all contained reporters.
- For all other attributes, it returns attribute of a first reporter
- which has this attribute using the order in which the reporters were
- provided.
- """
- def __init__(self, reporters, forgiving=False):
- self.reporters = reporters
- self.forgiving = forgiving
- def start(self, results_dir):
- # TODO: no directory cleaning (self.clean_before)? now cleaned by caller
- # TODO: perhaps only those whoe need it should do it (even multiple times)
- # and there is also the delet problem
- ensure_dir(os.path.abspath(results_dir))
- for reporter in self.reporters:
- try:
- reporter.start(results_dir)
- except AttributeError:
- if self.forgiving:
- pass
- else:
- raise
- def finish(self):
- for reporter in self.reporters:
- try:
- reporter.finish()
- except AttributeError:
- if self.forgiving:
- pass
- else:
- raise
- def start_file_test(self, module):
- for reporter in self.reporters:
- try:
- reporter.start_file_test(module)
- except AttributeError:
- if self.forgiving:
- pass
- else:
- raise
- def end_file_test(self, **kwargs):
- for reporter in self.reporters:
- try:
- reporter.end_file_test(**kwargs)
- except AttributeError:
- if self.forgiving:
- pass
- else:
- raise
- def __getattr__(self, name):
- for reporter in self.reporters:
- try:
- return getattr(reporter, name)
- except AttributeError:
- continue
- raise AttributeError
- class GrassTestFilesCountingReporter(object):
- def __init__(self):
- self.test_files = None
- self.files_fail = None
- self.files_pass = None
- self.file_pass_per = None
- self.file_fail_per = None
- self.main_start_time = None
- self.main_end_time = None
- self.main_time = None
- self.file_start_time = None
- self.file_end_time = None
- self.file_time = None
- self._start_file_test_called = False
- def start(self, results_dir):
- self.test_files = 0
- self.files_fail = 0
- self.files_pass = 0
- # this might be moved to some report start method
- self.main_start_time = datetime.datetime.now()
- def finish(self):
- self.main_end_time = datetime.datetime.now()
- self.main_time = self.main_end_time - self.main_start_time
- assert self.test_files == self.files_fail + self.files_pass
- if self.test_files:
- self.file_pass_per = 100 * float(self.files_pass) / self.test_files
- self.file_fail_per = 100 * float(self.files_fail) / self.test_files
- else:
- # if no tests were executed, probably something bad happened
- # try to report at least something
- self.file_pass_per = None
- self.file_fail_per = None
- def start_file_test(self, module):
- self.file_start_time = datetime.datetime.now()
- self._start_file_test_called = True
- self.test_files += 1
- def end_file_test(self, returncode, **kwargs):
- assert self._start_file_test_called
- self.file_end_time = datetime.datetime.now()
- self.file_time = self.file_end_time - self.file_start_time
- if returncode:
- self.files_fail += 1
- else:
- self.files_pass += 1
- self._start_file_test_called = False
- def percent_to_html(percent):
- if percent is None:
- return '<span style="color: {color}">unknown percentage</span>'
- elif percent > 100 or percent < 0:
- return "? {:.2f}% ?".format(percent)
- elif percent < 40:
- color = "red"
- elif percent < 70:
- color = "orange"
- else:
- color = "green"
- return '<span style="color: {color}">{percent:.0f}%</span>'.format(
- percent=percent, color=color
- )
- def wrap_stdstream_to_html(infile, outfile, module, stream):
- before = "<html><body><h1>%s</h1><pre>" % (module.name + " " + stream)
- after = "</pre></body></html>"
- html = open(outfile, "w")
- html.write(before)
- with open(infile) as text:
- for line in text:
- html.write(color_error_line(html_escape(line)))
- html.write(after)
- html.close()
- def html_file_preview(filename):
- before = "<pre>"
- after = "</pre>"
- if not os.path.isfile(filename):
- return '<p style="color: red>File %s does not exist</p>' % filename
- size = os.path.getsize(filename)
- if not size:
- return '<p style="color: red>File %s is empty</p>' % filename
- max_size = 10000
- html = StringIO()
- html.write(before)
- if size < max_size:
- with open(filename) as text:
- for line in text:
- html.write(color_error_line(html_escape(line)))
- elif size < 10 * max_size:
- def tail(filename, n):
- return collections.deque(open(filename), n)
- html.write("... (lines omitted)\n")
- for line in tail(filename, 50):
- html.write(color_error_line(html_escape(line)))
- else:
- return '<p style="color: red>File %s is too large to show</p>' % filename
- html.write(after)
- return html.getvalue()
- def returncode_to_html_text(returncode):
- if returncode:
- return '<span style="color: red">FAILED</span>'
- else:
- # alternatives: SUCCEEDED, passed, OK
- return '<span style="color: green">succeeded</span>'
- # not used
- def returncode_to_html_sentence(returncode):
- if returncode:
- return (
- '<span style="color: red">❌</span>'
- " Test failed (return code %d)" % (returncode)
- )
- else:
- return (
- '<span style="color: green">✓</span>'
- " Test succeeded (return code %d)" % (returncode)
- )
- def returncode_to_success_html_par(returncode):
- if returncode:
- return '<p> <span style="color: red">❌</span>' " Test failed</p>"
- else:
- return '<p> <span style="color: green">✓</span>' " Test succeeded</p>"
- def success_to_html_text(total, successes):
- if successes < total:
- return '<span style="color: red">FAILED</span>'
- elif successes == total:
- # alternatives: SUCCEEDED, passed, OK
- return '<span style="color: green">succeeded</span>'
- else:
- return (
- '<span style="color: red; font-size: 60%">'
- "? more successes than total ?</span>"
- )
- UNKNOWN_NUMBER_HTML = '<span style="font-size: 60%">unknown</span>'
- def success_to_html_percent(total, successes):
- if total:
- pass_per = 100 * (float(successes) / total)
- pass_per = percent_to_html(pass_per)
- else:
- pass_per = UNKNOWN_NUMBER_HTML
- return pass_per
- class GrassTestFilesHtmlReporter(GrassTestFilesCountingReporter):
- unknown_number = UNKNOWN_NUMBER_HTML
- def __init__(self, file_anonymizer, main_page_name="index.html"):
- super(GrassTestFilesHtmlReporter, self).__init__()
- self.main_index = None
- self._file_anonymizer = file_anonymizer
- self._main_page_name = main_page_name
- def start(self, results_dir):
- super(GrassTestFilesHtmlReporter, self).start(results_dir)
- # having all variables public although not really part of API
- main_page_name = os.path.join(results_dir, self._main_page_name)
- self.main_index = open(main_page_name, "w")
- # TODO: this can be moved to the counter class
- self.failures = 0
- self.errors = 0
- self.skipped = 0
- self.successes = 0
- self.expected_failures = 0
- self.unexpected_success = 0
- self.total = 0
- svn_info = get_svn_info()
- if not svn_info:
- svn_text = (
- '<span style="font-size: 60%">'
- "SVN revision cannot be obtained"
- "</span>"
- )
- else:
- url = get_source_url(
- path=svn_info["relative-url"], revision=svn_info["revision"]
- )
- svn_text = ("SVN revision" ' <a href="{url}">' "{rev}</a>").format(
- url=url, rev=svn_info["revision"]
- )
- self.main_index.write(
- "<html><body>"
- "<h1>Test results</h1>"
- "{time:%Y-%m-%d %H:%M:%S}"
- " ({svn})"
- "<table>"
- "<thead><tr>"
- "<th>Tested directory</th>"
- "<th>Test file</th>"
- "<th>Status</th>"
- "<th>Tests</th><th>Successful</td>"
- "<th>Failed</th><th>Percent successful</th>"
- "</tr></thead><tbody>".format(time=self.main_start_time, svn=svn_text)
- )
- def finish(self):
- super(GrassTestFilesHtmlReporter, self).finish()
- pass_per = success_to_html_percent(total=self.total, successes=self.successes)
- tfoot = (
- "<tfoot>"
- "<tr>"
- "<td>Summary</td>"
- "<td>{nfiles} test files</td>"
- "<td>{nsper}</td>"
- "<td>{total}</td><td>{st}</td><td>{ft}</td><td>{pt}</td>"
- "</tr>"
- "</tfoot>".format(
- nfiles=self.test_files,
- nsper=percent_to_html(self.file_pass_per),
- st=self.successes,
- ft=self.failures + self.errors,
- total=self.total,
- pt=pass_per,
- )
- )
- # this is the second place with this function
- # TODO: provide one implementation
- def format_percentage(percentage):
- if percentage is not None:
- return "{nsper:.0f}%".format(nsper=percentage)
- else:
- return "unknown percentage"
- summary_sentence = (
- "\nExecuted {nfiles} test files in {time:}."
- "\nFrom them"
- " {nsfiles} files ({nsper}) were successful"
- " and {nffiles} files ({nfper}) failed.\n".format(
- nfiles=self.test_files,
- time=self.main_time,
- nsfiles=self.files_pass,
- nffiles=self.files_fail,
- nsper=format_percentage(self.file_pass_per),
- nfper=format_percentage(self.file_fail_per),
- )
- )
- self.main_index.write(
- "<tbody>{tfoot}</table>"
- "<p>{summary}</p>"
- "</body></html>".format(tfoot=tfoot, summary=summary_sentence)
- )
- self.main_index.close()
- def start_file_test(self, module):
- super(GrassTestFilesHtmlReporter, self).start_file_test(module)
- self.main_index.flush() # to get previous lines to the report
- def end_file_test(self, module, cwd, returncode, stdout, stderr, test_summary):
- super(GrassTestFilesHtmlReporter, self).end_file_test(
- module=module, cwd=cwd, returncode=returncode, stdout=stdout, stderr=stderr
- )
- # considering others according to total is OK when we more or less
- # know that input data make sense (total >= errors + failures)
- total = test_summary.get("total", None)
- failures = test_summary.get("failures", 0)
- errors = test_summary.get("errors", 0)
- # Python unittest TestResult class is reporting success for no
- # errors or failures, so skipped, expected failures and unexpected
- # success are ignored
- # but successful tests are only total - the others
- # TODO: add success counter to GrassTestResult base class
- skipped = test_summary.get("skipped", 0)
- expected_failures = test_summary.get("expected_failures", 0)
- unexpected_successes = test_summary.get("unexpected_successes", 0)
- successes = test_summary.get("successes", 0)
- self.failures += failures
- self.errors += errors
- self.skipped += skipped
- self.expected_failures += expected_failures
- self.unexpected_success += unexpected_successes
- # zero would be valid here
- if total is not None:
- # success are only the clear ones
- # percentage is influenced by all
- # but putting only failures to table
- self.successes += successes
- self.total += total
- # this will handle zero
- pass_per = success_to_html_percent(total=total, successes=successes)
- else:
- total = successes = pass_per = self.unknown_number
- bad_ones = failures + errors
- self.main_index.write(
- "<tr><td>{d}</td>"
- '<td><a href="{d}/{m}/index.html">{m}</a></td>'
- "<td>{status}</td>"
- "<td>{ntests}</td><td>{stests}</td>"
- "<td>{ftests}</td><td>{ptests}</td>"
- "<tr>".format(
- d=to_web_path(module.tested_dir),
- m=module.name,
- status=returncode_to_html_text(returncode),
- stests=successes,
- ftests=bad_ones,
- ntests=total,
- ptests=pass_per,
- )
- )
- wrap_stdstream_to_html(
- infile=stdout,
- outfile=os.path.join(cwd, "stdout.html"),
- module=module,
- stream="stdout",
- )
- wrap_stdstream_to_html(
- infile=stderr,
- outfile=os.path.join(cwd, "stderr.html"),
- module=module,
- stream="stderr",
- )
- file_index_path = os.path.join(cwd, "index.html")
- file_index = open(file_index_path, "w")
- file_index.write(
- '<!DOCTYPE html><html><head><meta charset="utf-8"></head><body>'
- "<h1>{m.name}</h1>"
- "<h2>{m.tested_dir} – {m.name}</h2>"
- "{status}".format(
- m=module,
- status=returncode_to_success_html_par(returncode),
- )
- )
- # TODO: include optionally hyper link to test suite
- # TODO: file_path is reconstucted in a naive way
- # file_path should be stored in the module/test file object and just used here
- summary_section = (
- "<table><tbody>"
- "<tr><td>Test</td><td>{m}</td></tr>"
- "<tr><td>Testsuite</td><td>{d}</td></tr>"
- "<tr><td>Test file</td><td>{file_path}</td></tr>"
- "<tr><td>Status</td><td>{status}</td></tr>"
- "<tr><td>Return code</td><td>{rc}</td></tr>"
- "<tr><td>Number of tests</td><td>{ntests}</td></tr>"
- "<tr><td>Successful tests</td><td>{stests}</td></tr>"
- "<tr><td>Failed tests</td><td>{ftests}</td></tr>"
- "<tr><td>Percent successful</td><td>{ptests}</td></tr>"
- "<tr><td>Test duration</td><td>{dur}</td></tr>".format(
- d=module.tested_dir,
- m=module.name,
- file_path=os.path.join(
- module.tested_dir, "testsuite", module.name + "." + module.file_type
- ),
- status=returncode_to_html_text(returncode),
- stests=successes,
- ftests=bad_ones,
- ntests=total,
- ptests=pass_per,
- rc=returncode,
- dur=self.file_time,
- )
- )
- file_index.write(summary_section)
- modules = test_summary.get("tested_modules", None)
- if modules:
- # TODO: replace by better handling of potential lists when parsing
- # TODO: create link to module if running in grass or in addons
- # alternatively a link to module test summary
- if type(modules) is not list:
- modules = [modules]
- file_index.write(
- "<tr><td>Tested modules</td><td>{0}</td></tr>".format(
- ", ".join(sorted(set(modules)))
- )
- )
- file_index.write("</tbody></table>")
- # here we would have also links to coverage, profiling, ...
- # '<li><a href="testcodecoverage/index.html">code coverage</a></li>'
- files_section = (
- "<h3>Supplementary files</h3>"
- "<ul>"
- '<li><a href="stdout.html">standard output (stdout)</a></li>'
- '<li><a href="stderr.html">standard error output (stderr)</a></li>'
- )
- file_index.write(files_section)
- supplementary_files = test_summary.get("supplementary_files", None)
- if supplementary_files:
- # this is something we might want to do once for all and not
- # risk that it will be done twice or rely that somebody else
- # will do it for use
- # the solution is perhaps do the multi reporter more grass-specific
- # and do all common things, so that other can rely on it and
- # moreover something can be shared with other explicitly
- # using constructors as seems advantageous for counting
- self._file_anonymizer.anonymize(supplementary_files)
- for f in supplementary_files:
- file_index.write('<li><a href="{f}">{f}</a></li>'.format(f=f))
- file_index.write("</ul>")
- if returncode:
- file_index.write("<h3>Standard error output (stderr)</h3>")
- file_index.write(html_file_preview(stderr))
- file_index.write("</body></html>")
- file_index.close()
- if returncode:
- pass
- # TODO: here we don't have opportunity to write error file
- # to stream (stdout/stderr)
- # a stream can be added and if not none, we could write
- # TODO: document info: additional information to be stored type: dict
- # allows overwriting what was collected
- class GrassTestFilesKeyValueReporter(GrassTestFilesCountingReporter):
- def __init__(self, info=None):
- super(GrassTestFilesKeyValueReporter, self).__init__()
- self.result_dir = None
- self._info = info
- def start(self, results_dir):
- super(GrassTestFilesKeyValueReporter, self).start(results_dir)
- # having all variables public although not really part of API
- self.result_dir = results_dir
- # TODO: this can be moved to the counter class
- self.failures = 0
- self.errors = 0
- self.skipped = 0
- self.successes = 0
- self.expected_failures = 0
- self.unexpected_success = 0
- self.total = 0
- # TODO: document: tested_dirs is a list and it should fit with names
- self.names = []
- self.tested_dirs = []
- self.files_returncodes = []
- # sets (no size specified)
- self.modules = set()
- self.test_files_authors = set()
- def finish(self):
- super(GrassTestFilesKeyValueReporter, self).finish()
- # this shoul be moved to some additional meta passed in constructor
- svn_info = get_svn_info()
- if not svn_info:
- svn_revision = ""
- else:
- svn_revision = svn_info["revision"]
- summary = {}
- summary["files_total"] = self.test_files
- summary["files_successes"] = self.files_pass
- summary["files_failures"] = self.files_fail
- summary["names"] = self.names
- summary["tested_dirs"] = self.tested_dirs
- # TODO: we don't have a general mechanism for storing any type in text
- summary["files_returncodes"] = [str(item) for item in self.files_returncodes]
- # let's use seconds as a universal time delta format
- # (there is no standard way how to store time delta as string)
- summary["time"] = self.main_time.total_seconds()
- status = "failed" if self.files_fail else "succeeded"
- summary["status"] = status
- summary["total"] = self.total
- summary["successes"] = self.successes
- summary["failures"] = self.failures
- summary["errors"] = self.errors
- summary["skipped"] = self.skipped
- summary["expected_failures"] = self.expected_failures
- summary["unexpected_successes"] = self.unexpected_success
- summary["test_files_authors"] = self.test_files_authors
- summary["tested_modules"] = self.modules
- summary["svn_revision"] = svn_revision
- # ignoring issues with time zones
- summary["timestamp"] = self.main_start_time.strftime("%Y-%m-%d %H:%M:%S")
- # TODO: add some general metadata here (passed in constructor)
- # add additional information
- for key, value in self._info.items():
- summary[key] = value
- summary_filename = os.path.join(self.result_dir, "test_keyvalue_result.txt")
- with open(summary_filename, "w") as summary_file:
- text = keyvalue_to_text(summary, sep="=", vsep="\n", isep=",")
- summary_file.write(text)
- def end_file_test(self, module, cwd, returncode, stdout, stderr, test_summary):
- super(GrassTestFilesKeyValueReporter, self).end_file_test(
- module=module, cwd=cwd, returncode=returncode, stdout=stdout, stderr=stderr
- )
- # TODO: considering others according to total, OK?
- # here we are using 0 for total but HTML reporter is using None
- total = test_summary.get("total", 0)
- failures = test_summary.get("failures", 0)
- errors = test_summary.get("errors", 0)
- # Python unittest TestResult class is reporting success for no
- # errors or failures, so skipped, expected failures and unexpected
- # success are ignored
- # but successful tests are only total - the others
- skipped = test_summary.get("skipped", 0)
- expected_failures = test_summary.get("expected_failures", 0)
- unexpected_successes = test_summary.get("unexpected_successes", 0)
- successes = test_summary.get("successes", 0)
- # TODO: move this to counter class and perhaps use aggregation
- # rather then inheritance
- self.failures += failures
- self.errors += errors
- self.skipped += skipped
- self.expected_failures += expected_failures
- self.unexpected_success += unexpected_successes
- # TODO: should we test for zero?
- if total is not None:
- # success are only the clear ones
- # percentage is influenced by all
- # but putting only failures to table
- self.successes += successes
- self.total += total
- self.files_returncodes.append(returncode)
- self.tested_dirs.append(module.tested_dir)
- self.names.append(module.name)
- modules = test_summary.get("tested_modules", None)
- if modules:
- # TODO: replace by better handling of potential lists when parsing
- # TODO: create link to module if running in grass or in addons
- # alternatively a link to module test summary
- if type(modules) not in [list, set]:
- modules = [modules]
- self.modules.update(modules)
- test_file_authors = test_summary["test_file_authors"]
- if type(test_file_authors) not in [list, set]:
- test_file_authors = [test_file_authors]
- self.test_files_authors.update(test_file_authors)
- class GrassTestFilesTextReporter(GrassTestFilesCountingReporter):
- def __init__(self, stream):
- super(GrassTestFilesTextReporter, self).__init__()
- self._stream = stream
- def start(self, results_dir):
- super(GrassTestFilesTextReporter, self).start(results_dir)
- def finish(self):
- super(GrassTestFilesTextReporter, self).finish()
- def format_percentage(percentage):
- if percentage is not None:
- return "{nsper:.0f}%".format(nsper=percentage)
- else:
- return "unknown percentage"
- summary_sentence = (
- "\nExecuted {nfiles} test files in {time:}."
- "\nFrom them"
- " {nsfiles} files ({nsper}) were successful"
- " and {nffiles} files ({nfper}) failed.\n".format(
- nfiles=self.test_files,
- time=self.main_time,
- nsfiles=self.files_pass,
- nffiles=self.files_fail,
- nsper=format_percentage(self.file_pass_per),
- nfper=format_percentage(self.file_fail_per),
- )
- )
- self._stream.write(summary_sentence)
- def start_file_test(self, module):
- super(GrassTestFilesTextReporter, self).start_file_test(module)
- self._stream.write("Running {file}...\n".format(file=module.file_path))
- # get the above line and all previous ones to the report
- self._stream.flush()
- def end_file_test(self, module, cwd, returncode, stdout, stderr, test_summary):
- super(GrassTestFilesTextReporter, self).end_file_test(
- module=module, cwd=cwd, returncode=returncode, stdout=stdout, stderr=stderr
- )
- if returncode:
- width = 72
- self._stream.write(width * "=")
- self._stream.write("\n")
- with open(stderr) as text:
- self._stream.write(text.read())
- self._stream.write(width * "=")
- self._stream.write("\n")
- self._stream.write("FAILED {file}".format(file=module.file_path))
- num_failed = test_summary.get("failures", 0)
- num_failed += test_summary.get("errors", 0)
- if num_failed:
- if num_failed > 1:
- text = " ({f} tests failed)"
- else:
- text = " ({f} test failed)"
- self._stream.write(text.format(f=num_failed))
- self._stream.write("\n")
- # TODO: here we lost the possibility to include also file name
- # of the appropriate report
- # TODO: there is a quite a lot duplication between this class and html reporter
- # TODO: document: do not use it for two reports, it accumulates the results
- # TODO: add also keyvalue summary generation?
- # wouldn't this conflict with collecting data from report afterwards?
- class TestsuiteDirReporter(object):
- def __init__(
- self,
- main_page_name,
- testsuite_page_name="index.html",
- top_level_testsuite_page_name=None,
- ):
- self.main_page_name = main_page_name
- self.testsuite_page_name = testsuite_page_name
- self.top_level_testsuite_page_name = top_level_testsuite_page_name
- # TODO: this might be even a object which could add and validate
- self.failures = 0
- self.errors = 0
- self.skipped = 0
- self.successes = 0
- self.expected_failures = 0
- self.unexpected_successes = 0
- self.total = 0
- self.testsuites = 0
- self.testsuites_successes = 0
- self.files = 0
- self.files_successes = 0
- def report_for_dir(self, root, directory, test_files):
- # TODO: create object from this, so that it can be passed from
- # one function to another
- # TODO: put the inside of for loop to another function
- dir_failures = 0
- dir_errors = 0
- dir_skipped = 0
- dir_successes = 0
- dir_expected_failures = 0
- dir_unexpected_success = 0
- dir_total = 0
- test_files_authors = []
- file_total = 0
- file_successes = 0
- page_name = os.path.join(root, directory, self.testsuite_page_name)
- if self.top_level_testsuite_page_name and os.path.abspath(
- os.path.join(root, directory)
- ) == os.path.abspath(root):
- page_name = os.path.join(root, self.top_level_testsuite_page_name)
- page = open(page_name, "w")
- # TODO: should we use forward slashes also for the HTML because
- # it is simpler are more consistent with the rest on MS Windows?
- head = "<html><body>" "<h1>{name} testsuite results</h1>".format(name=directory)
- tests_table_head = (
- "<h3>Test files results</h3>"
- "<table>"
- "<thead><tr>"
- "<th>Test file</th><th>Status</th>"
- "<th>Tests</th><th>Successful</td>"
- "<th>Failed</th><th>Percent successful</th>"
- "</tr></thead><tbody>"
- )
- page.write(head)
- page.write(tests_table_head)
- for test_file_name in test_files:
- # TODO: put keyvalue fine name to constant
- summary_filename = os.path.join(
- root, directory, test_file_name, "test_keyvalue_result.txt"
- )
- # if os.path.exists(summary_filename):
- with open(summary_filename, "r") as keyval_file:
- summary = text_to_keyvalue(keyval_file.read(), sep="=")
- # else:
- # TODO: write else here
- # summary = None
- if "total" not in summary:
- bad_ones = successes = UNKNOWN_NUMBER_HTML
- total = None
- else:
- bad_ones = summary["failures"] + summary["errors"]
- successes = summary["successes"]
- total = summary["total"]
- self.failures += summary["failures"]
- self.errors += summary["errors"]
- self.skipped += summary["skipped"]
- self.successes += summary["successes"]
- self.expected_failures += summary["expected_failures"]
- self.unexpected_successes += summary["unexpected_successes"]
- self.total += summary["total"]
- dir_failures += summary["failures"]
- dir_errors += summary["failures"]
- dir_skipped += summary["skipped"]
- dir_successes += summary["successes"]
- dir_expected_failures += summary["expected_failures"]
- dir_unexpected_success += summary["unexpected_successes"]
- dir_total += summary["total"]
- # TODO: keyvalue method should have types for keys function
- # perhaps just the current post processing function is enough
- test_file_authors = summary["test_file_authors"]
- if type(test_file_authors) is not list:
- test_file_authors = [test_file_authors]
- test_files_authors.extend(test_file_authors)
- file_total += 1
- file_successes += 0 if summary["returncode"] else 1
- pass_per = success_to_html_percent(total=total, successes=successes)
- row = (
- "<tr>"
- '<td><a href="{f}/index.html">{f}</a></td>'
- "<td>{status}</td>"
- "<td>{ntests}</td><td>{stests}</td>"
- "<td>{ftests}</td><td>{ptests}</td>"
- "<tr>".format(
- f=test_file_name,
- status=returncode_to_html_text(summary["returncode"]),
- stests=successes,
- ftests=bad_ones,
- ntests=total,
- ptests=pass_per,
- )
- )
- page.write(row)
- self.testsuites += 1
- self.testsuites_successes += 1 if file_successes == file_total else 0
- self.files += file_total
- self.files_successes += file_successes
- dir_pass_per = success_to_html_percent(total=dir_total, successes=dir_successes)
- file_pass_per = success_to_html_percent(
- total=file_total, successes=file_successes
- )
- tests_table_foot = (
- "</tbody><tfoot><tr>"
- "<td>Summary</td>"
- "<td>{status}</td>"
- "<td>{ntests}</td><td>{stests}</td>"
- "<td>{ftests}</td><td>{ptests}</td>"
- "</tr></tfoot></table>".format(
- status=file_pass_per,
- stests=dir_successes,
- ftests=dir_failures + dir_errors,
- ntests=dir_total,
- ptests=dir_pass_per,
- )
- )
- page.write(tests_table_foot)
- test_authors = get_html_test_authors_table(
- directory=directory, tests_authors=test_files_authors
- )
- page.write(test_authors)
- page.write("</body></html>")
- page.close()
- status = success_to_html_text(total=file_total, successes=file_successes)
- row = (
- "<tr>"
- '<td><a href="{d}/{page}">{d}</a></td><td>{status}</td>'
- "<td>{nfiles}</td><td>{sfiles}</td><td>{pfiles}</td>"
- "<td>{ntests}</td><td>{stests}</td>"
- "<td>{ftests}</td><td>{ptests}</td>"
- "<tr>".format(
- d=to_web_path(directory),
- page=self.testsuite_page_name,
- status=status,
- nfiles=file_total,
- sfiles=file_successes,
- pfiles=file_pass_per,
- stests=dir_successes,
- ftests=dir_failures + dir_errors,
- ntests=dir_total,
- ptests=dir_pass_per,
- )
- )
- return row
- def report_for_dirs(self, root, directories):
- # TODO: this will need chanages according to potential changes in absolute/relative paths
- page_name = os.path.join(root, self.main_page_name)
- page = open(page_name, "w")
- head = "<html><body>" "<h1>Testsuites results</h1>"
- tests_table_head = (
- "<table>"
- "<thead><tr>"
- "<th>Testsuite</th>"
- "<th>Status</th>"
- "<th>Test files</th><th>Successful</td>"
- "<th>Percent successful</th>"
- "<th>Tests</th><th>Successful</td>"
- "<th>Failed</th><th>Percent successful</th>"
- "</tr></thead><tbody>"
- )
- page.write(head)
- page.write(tests_table_head)
- for directory, test_files in directories.items():
- row = self.report_for_dir(
- root=root, directory=directory, test_files=test_files
- )
- page.write(row)
- pass_per = success_to_html_percent(total=self.total, successes=self.successes)
- file_pass_per = success_to_html_percent(
- total=self.files, successes=self.files_successes
- )
- testsuites_pass_per = success_to_html_percent(
- total=self.testsuites, successes=self.testsuites_successes
- )
- tests_table_foot = (
- "<tfoot>"
- "<tr>"
- "<td>Summary</td><td>{status}</td>"
- "<td>{nfiles}</td><td>{sfiles}</td><td>{pfiles}</td>"
- "<td>{ntests}</td><td>{stests}</td>"
- "<td>{ftests}</td><td>{ptests}</td>"
- "</tr>"
- "</tfoot>".format(
- status=testsuites_pass_per,
- nfiles=self.files,
- sfiles=self.files_successes,
- pfiles=file_pass_per,
- stests=self.successes,
- ftests=self.failures + self.errors,
- ntests=self.total,
- ptests=pass_per,
- )
- )
- page.write(tests_table_foot)
- page.write("</body></html>")
|