invoker.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. # -*- coding: utf-8 -*-
  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. detected = False
  193. idx = 0
  194. while not detected:
  195. try:
  196. stdout = decode(stdout, encoding=encodings[idx])
  197. detected = True
  198. except:
  199. idx += 1
  200. pass
  201. detected = False
  202. idx = 0
  203. while not detected:
  204. try:
  205. stderr = decode(stderr, encoding=encodings[idx])
  206. detected = True
  207. except:
  208. idx += 1
  209. pass
  210. with open(stdout_path, "w") as stdout_file:
  211. stdout_file.write(stdout)
  212. with open(stderr_path, "w") as stderr_file:
  213. if type(stderr) == "bytes":
  214. stderr_file.write(decode(stderr))
  215. else:
  216. if isinstance(stderr, str):
  217. stderr_file.write(stderr)
  218. else:
  219. stderr_file.write(stderr.encode("utf8"))
  220. self._file_anonymizer.anonymize([stdout_path, stderr_path])
  221. test_summary = update_keyval_file(
  222. os.path.join(os.path.abspath(cwd), "test_keyvalue_result.txt"),
  223. module=module,
  224. returncode=returncode,
  225. )
  226. self.reporter.end_file_test(
  227. module=module,
  228. cwd=cwd,
  229. returncode=returncode,
  230. stdout=stdout_path,
  231. stderr=stderr_path,
  232. test_summary=test_summary,
  233. )
  234. # TODO: add some try-except or with for better error handling
  235. os.remove(gisrc)
  236. # TODO: only if clean up
  237. if self.clean_mapsets:
  238. shutil.rmtree(mapset_dir)
  239. def run_in_location(self, gisdbase, location, location_type, results_dir):
  240. """Run tests in a given location
  241. Returns an object with counting attributes of GrassTestFilesCountingReporter,
  242. i.e., a file-oriented reporter as opposed to testsuite-oriented one.
  243. Use only the attributes related to the summary, such as file_pass_per,
  244. not to one file as these will simply contain the last executed file.
  245. """
  246. if os.path.abspath(results_dir) == os.path.abspath(self.start_dir):
  247. raise RuntimeError(
  248. "Results root directory should not be the same"
  249. " as discovery start directory"
  250. )
  251. self.reporter = GrassTestFilesMultiReporter(
  252. reporters=[
  253. GrassTestFilesTextReporter(stream=sys.stderr),
  254. GrassTestFilesHtmlReporter(
  255. file_anonymizer=self._file_anonymizer,
  256. main_page_name="testfiles.html",
  257. ),
  258. GrassTestFilesKeyValueReporter(
  259. info=dict(location=location, location_type=location_type)
  260. ),
  261. ]
  262. )
  263. self.testsuite_dirs = collections.defaultdict(
  264. list
  265. ) # reset list of dirs each time
  266. # TODO: move constants out of loader class or even module
  267. modules = discover_modules(
  268. start_dir=self.start_dir,
  269. grass_location=location_type,
  270. file_regexp=r".*\.(py|sh)$",
  271. skip_dirs=GrassTestLoader.skip_dirs,
  272. testsuite_dir=GrassTestLoader.testsuite_dir,
  273. all_locations_value=GrassTestLoader.all_tests_value,
  274. universal_location_value=GrassTestLoader.universal_tests_value,
  275. import_modules=False,
  276. )
  277. self.reporter.start(results_dir)
  278. for module in modules:
  279. self._run_test_module(
  280. module=module,
  281. results_dir=results_dir,
  282. gisdbase=gisdbase,
  283. location=location,
  284. )
  285. self.reporter.finish()
  286. # TODO: move this to some (new?) reporter
  287. # TODO: add basic summary of linked files so that the page is not empty
  288. with open(os.path.join(results_dir, "index.html"), "w") as main_index:
  289. main_index.write(
  290. "<html><body>"
  291. "<h1>Tests for &lt;{location}&gt;"
  292. " using &lt;{type}&gt; type tests</h1>"
  293. "<ul>"
  294. '<li><a href="testsuites.html">Results by testsuites</a>'
  295. " (testsuite directories)</li>"
  296. '<li><a href="testfiles.html">Results by test files</a></li>'
  297. "<ul>"
  298. "</body></html>".format(location=location, type=location_type)
  299. )
  300. testsuite_dir_reporter = TestsuiteDirReporter(
  301. main_page_name="testsuites.html",
  302. testsuite_page_name="index.html",
  303. top_level_testsuite_page_name="testsuite_index.html",
  304. )
  305. testsuite_dir_reporter.report_for_dirs(
  306. root=results_dir, directories=self.testsuite_dirs
  307. )
  308. return self.reporter