invoker.py 13 KB

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