invoker.py 13 KB

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