invoker.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. """
  2. GRASS Python testing framework test files invoker (runner)
  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 sys
  11. import shutil
  12. import subprocess
  13. from .checkers import text_to_keyvalue
  14. from .loader import GrassTestLoader, discover_modules
  15. from .reporters import (
  16. GrassTestFilesMultiReporter,
  17. GrassTestFilesTextReporter,
  18. GrassTestFilesHtmlReporter,
  19. TestsuiteDirReporter,
  20. GrassTestFilesKeyValueReporter,
  21. get_svn_path_authors,
  22. NoopFileAnonymizer,
  23. keyvalue_to_text,
  24. )
  25. from .utils import silent_rmtree, ensure_dir
  26. from grass.script.utils import decode, _get_encoding
  27. try:
  28. from string import maketrans
  29. except ImportError:
  30. maketrans = str.maketrans
  31. # needed for write_gisrc
  32. # TODO: it would be good to find some way of writing rc without the need to
  33. # have GRASS proprly set (anything from grass.script requires translations to
  34. # be set, i.e. the GRASS environment properly set)
  35. import grass.script.setup as gsetup
  36. import collections
  37. # TODO: this might be more extend then update
  38. def update_keyval_file(filename, module, returncode):
  39. if os.path.exists(filename):
  40. with open(filename, "r") as keyval_file:
  41. keyval = text_to_keyvalue(keyval_file.read(), sep="=")
  42. else:
  43. keyval = {}
  44. # this is for one file
  45. test_file_authors = get_svn_path_authors(module.abs_file_path)
  46. # in case that SVN is not available use empty authors
  47. if test_file_authors is None:
  48. test_file_authors = ""
  49. # always owerwrite name and status
  50. keyval["name"] = module.name
  51. keyval["tested_dir"] = module.tested_dir
  52. if "status" not in keyval.keys():
  53. keyval["status"] = "failed" if returncode else "passed"
  54. keyval["returncode"] = returncode
  55. keyval["test_file_authors"] = test_file_authors
  56. with open(filename, "w") as keyval_file:
  57. keyval_file.write(keyvalue_to_text(keyval))
  58. return keyval
  59. class GrassTestFilesInvoker(object):
  60. """A class used to invoke test files and create the main report"""
  61. # TODO: it is not clear what clean_outputs mean, if should be split
  62. # std stream, random outputs, saved results, profiling
  63. # not stdout and stderr if they contain test results
  64. # we can also save only failed tests, or generate only if assert fails
  65. def __init__(
  66. self,
  67. start_dir,
  68. clean_mapsets=True,
  69. clean_outputs=True,
  70. clean_before=True,
  71. testsuite_dir="testsuite",
  72. file_anonymizer=None,
  73. ):
  74. """
  75. :param bool clean_mapsets: if the mapsets should be removed
  76. :param bool clean_outputs: meaning is unclear: random tests outputs,
  77. saved images from maps, profiling?
  78. :param bool clean_before: if mapsets, outputs, and results
  79. should be removed before the tests start
  80. (advantageous when the previous run left everything behind)
  81. """
  82. self.start_dir = start_dir
  83. self.clean_mapsets = clean_mapsets
  84. self.clean_outputs = clean_outputs
  85. self.clean_before = clean_before
  86. self.testsuite_dir = testsuite_dir # TODO: solve distribution of this constant
  87. # reporter is created for each call of run_in_location()
  88. self.reporter = None
  89. self.testsuite_dirs = None
  90. if file_anonymizer is None:
  91. self._file_anonymizer = NoopFileAnonymizer()
  92. else:
  93. self._file_anonymizer = file_anonymizer
  94. def _create_mapset(self, gisdbase, location, module):
  95. """Create mapset according to information in module.
  96. :param loader.GrassTestPythonModule module:
  97. """
  98. # TODO: use g.mapset -c, no need to duplicate functionality
  99. # using path.sep but also / and \ for cases when it is confused
  100. # (namely the case of Unix path on MS Windows)
  101. # replace . to get rid of unclean path
  102. # TODO: clean paths
  103. # note that backslash cannot be at the end of raw string
  104. dir_as_name = module.tested_dir.translate(maketrans(r"/\.", "___"))
  105. mapset = dir_as_name + "_" + module.name
  106. # TODO: use grass module to do this? but we are not in the right gisdbase
  107. mapset_dir = os.path.join(gisdbase, location, mapset)
  108. if self.clean_before:
  109. silent_rmtree(mapset_dir)
  110. os.mkdir(mapset_dir)
  111. # TODO: default region in mapset will be what?
  112. # copy DEFAULT_WIND file from PERMANENT to WIND
  113. # TODO: this should be a function in grass.script (used also in gis_set.py, PyGRASS also has its way with Mapset)
  114. # TODO: are premisions an issue here?
  115. shutil.copy(
  116. os.path.join(gisdbase, location, "PERMANENT", "DEFAULT_WIND"),
  117. os.path.join(mapset_dir, "WIND"),
  118. )
  119. return mapset, mapset_dir
  120. def _run_test_module(self, module, results_dir, gisdbase, location):
  121. """Run one test file."""
  122. self.testsuite_dirs[module.tested_dir].append(module.name)
  123. cwd = os.path.join(results_dir, module.tested_dir, module.name)
  124. data_dir = os.path.join(module.file_dir, "data")
  125. if os.path.exists(data_dir):
  126. # TODO: link dir instead of copy tree and remove link afterwads
  127. # (removing is good because of testsuite dir in samplecode)
  128. # TODO: use different dir name in samplecode and test if it works
  129. shutil.copytree(
  130. data_dir,
  131. os.path.join(cwd, "data"),
  132. ignore=shutil.ignore_patterns("*.svn*"),
  133. )
  134. ensure_dir(os.path.abspath(cwd))
  135. # TODO: put this to constructor and copy here again
  136. env = os.environ.copy()
  137. mapset, mapset_dir = self._create_mapset(gisdbase, location, module)
  138. gisrc = gsetup.write_gisrc(gisdbase, location, mapset)
  139. # here is special setting of environmental variables for running tests
  140. # some of them might be set from outside in the future and if the list
  141. # will be long they should be stored somewhere separately
  142. # use custom gisrc, not current session gisrc
  143. env["GISRC"] = gisrc
  144. # percentage in plain format is 0...10...20... ...100
  145. env["GRASS_MESSAGE_FORMAT"] = "plain"
  146. stdout_path = os.path.join(cwd, "stdout.txt")
  147. stderr_path = os.path.join(cwd, "stderr.txt")
  148. self.reporter.start_file_test(module)
  149. # TODO: we might clean the directory here before test if non-empty
  150. if module.file_type == "py":
  151. # ignoring shebang line to use current Python
  152. # and also pass parameters to it
  153. # add also '-Qwarn'?
  154. if sys.version_info.major >= 3:
  155. args = [sys.executable, "-tt", module.abs_file_path]
  156. else:
  157. args = [sys.executable, "-tt", "-3", module.abs_file_path]
  158. p = subprocess.Popen(
  159. args, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE
  160. )
  161. elif module.file_type == "sh":
  162. # ignoring shebang line to pass parameters to shell
  163. # expecting system to have sh or something compatible
  164. # TODO: add some special checks for MS Windows
  165. # using -x to see commands in stderr
  166. # using -e to terminate fast
  167. # from dash manual:
  168. # -e errexit If not interactive, exit immediately if any
  169. # untested command fails. The exit status of a com‐
  170. # mand is considered to be explicitly tested if the
  171. # command is used to control an if, elif, while, or
  172. # until; or if the command is the left hand operand
  173. # of an '&&' or '||' operator.
  174. p = subprocess.Popen(
  175. ["sh", "-e", "-x", module.abs_file_path],
  176. cwd=cwd,
  177. env=env,
  178. stdout=subprocess.PIPE,
  179. stderr=subprocess.PIPE,
  180. )
  181. else:
  182. p = subprocess.Popen(
  183. [module.abs_file_path],
  184. cwd=cwd,
  185. env=env,
  186. stdout=subprocess.PIPE,
  187. stderr=subprocess.PIPE,
  188. )
  189. stdout, stderr = p.communicate()
  190. returncode = p.returncode
  191. encodings = [_get_encoding(), "utf8", "latin-1", "ascii"]
  192. def try_decode(data, encodings):
  193. """Try to decode data (bytes) using one of encodings
  194. Falls back to decoding as UTF-8 with replacement for bytes.
  195. Strings are returned unmodified.
  196. """
  197. for encoding in encodings:
  198. try:
  199. return decode(stdout, encoding=encoding)
  200. except UnicodeError:
  201. pass
  202. if isinstance(data, bytes):
  203. return data.decode(encoding="utf-8", errors="replace")
  204. return data
  205. stdout = try_decode(stdout, encodings=encodings)
  206. stderr = try_decode(stderr, encodings=encodings)
  207. with open(stdout_path, "w") as stdout_file:
  208. stdout_file.write(stdout)
  209. with open(stderr_path, "w") as stderr_file:
  210. if type(stderr) == "bytes":
  211. stderr_file.write(decode(stderr))
  212. else:
  213. if isinstance(stderr, str):
  214. stderr_file.write(stderr)
  215. else:
  216. stderr_file.write(stderr.encode("utf8"))
  217. self._file_anonymizer.anonymize([stdout_path, stderr_path])
  218. test_summary = update_keyval_file(
  219. os.path.join(os.path.abspath(cwd), "test_keyvalue_result.txt"),
  220. module=module,
  221. returncode=returncode,
  222. )
  223. self.reporter.end_file_test(
  224. module=module,
  225. cwd=cwd,
  226. returncode=returncode,
  227. stdout=stdout_path,
  228. stderr=stderr_path,
  229. test_summary=test_summary,
  230. )
  231. # TODO: add some try-except or with for better error handling
  232. os.remove(gisrc)
  233. # TODO: only if clean up
  234. if self.clean_mapsets:
  235. shutil.rmtree(mapset_dir)
  236. def run_in_location(self, gisdbase, location, location_type, results_dir, exclude):
  237. """Run tests in a given location
  238. Returns an object with counting attributes of GrassTestFilesCountingReporter,
  239. i.e., a file-oriented reporter as opposed to testsuite-oriented one.
  240. Use only the attributes related to the summary, such as file_pass_per,
  241. not to one file as these will simply contain the last executed file.
  242. """
  243. if os.path.abspath(results_dir) == os.path.abspath(self.start_dir):
  244. raise RuntimeError(
  245. "Results root directory should not be the same"
  246. " as discovery start directory"
  247. )
  248. self.reporter = GrassTestFilesMultiReporter(
  249. reporters=[
  250. GrassTestFilesTextReporter(stream=sys.stderr),
  251. GrassTestFilesHtmlReporter(
  252. file_anonymizer=self._file_anonymizer,
  253. main_page_name="testfiles.html",
  254. ),
  255. GrassTestFilesKeyValueReporter(
  256. info=dict(location=location, location_type=location_type)
  257. ),
  258. ]
  259. )
  260. self.testsuite_dirs = collections.defaultdict(
  261. list
  262. ) # reset list of dirs each time
  263. # TODO: move constants out of loader class or even module
  264. modules = discover_modules(
  265. start_dir=self.start_dir,
  266. grass_location=location_type,
  267. file_regexp=r".*\.(py|sh)$",
  268. skip_dirs=GrassTestLoader.skip_dirs,
  269. testsuite_dir=GrassTestLoader.testsuite_dir,
  270. all_locations_value=GrassTestLoader.all_tests_value,
  271. universal_location_value=GrassTestLoader.universal_tests_value,
  272. import_modules=False,
  273. exclude=exclude,
  274. )
  275. self.reporter.start(results_dir)
  276. for module in modules:
  277. self._run_test_module(
  278. module=module,
  279. results_dir=results_dir,
  280. gisdbase=gisdbase,
  281. location=location,
  282. )
  283. self.reporter.finish()
  284. # TODO: move this to some (new?) reporter
  285. # TODO: add basic summary of linked files so that the page is not empty
  286. with open(os.path.join(results_dir, "index.html"), "w") as main_index:
  287. main_index.write(
  288. "<html><body>"
  289. "<h1>Tests for &lt;{location}&gt;"
  290. " using &lt;{type}&gt; type tests</h1>"
  291. "<ul>"
  292. '<li><a href="testsuites.html">Results by testsuites</a>'
  293. " (testsuite directories)</li>"
  294. '<li><a href="testfiles.html">Results by test files</a></li>'
  295. "<ul>"
  296. "</body></html>".format(location=location, type=location_type)
  297. )
  298. testsuite_dir_reporter = TestsuiteDirReporter(
  299. main_page_name="testsuites.html",
  300. testsuite_page_name="index.html",
  301. top_level_testsuite_page_name="testsuite_index.html",
  302. )
  303. testsuite_dir_reporter.report_for_dirs(
  304. root=results_dir, directories=self.testsuite_dirs
  305. )
  306. return self.reporter