main.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. """
  2. GRASS Python testing framework module for running from command line
  3. Copyright (C) 2014-2021 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 argparse
  12. import configparser
  13. from pathlib import Path
  14. from unittest.main import TestProgram
  15. import grass.script.core as gs
  16. from .loader import GrassTestLoader
  17. from .runner import GrassTestRunner, MultiTestResult, TextTestResult, KeyValueTestResult
  18. from .invoker import GrassTestFilesInvoker
  19. from .utils import silent_rmtree
  20. from .reporters import FileAnonymizer
  21. class GrassTestProgram(TestProgram):
  22. """A class to be used by individual test files (wrapped in the function)"""
  23. def __init__(
  24. self,
  25. exit_at_end,
  26. grass_location,
  27. clean_outputs=True,
  28. unittest_argv=None,
  29. module=None,
  30. verbosity=1,
  31. failfast=None,
  32. catchbreak=None,
  33. ):
  34. """Prepares the tests in GRASS way and then runs the tests.
  35. :param bool clean_outputs: if outputs in mapset and in ?
  36. """
  37. self.test = None
  38. self.grass_location = grass_location
  39. # it is unclear what the exact behavior is in unittest
  40. # buffer stdout and stderr during tests
  41. buffer_stdout_stderr = False
  42. grass_loader = GrassTestLoader(grass_location=self.grass_location)
  43. text_result = TextTestResult(
  44. stream=sys.stderr, descriptions=True, verbosity=verbosity
  45. )
  46. with open("test_keyvalue_result.txt", "w") as keyval_file:
  47. keyval_result = KeyValueTestResult(stream=keyval_file)
  48. result = MultiTestResult(results=[text_result, keyval_result])
  49. grass_runner = GrassTestRunner(
  50. verbosity=verbosity,
  51. failfast=failfast,
  52. buffer=buffer_stdout_stderr,
  53. result=result,
  54. )
  55. super().__init__(
  56. module=module,
  57. argv=unittest_argv,
  58. testLoader=grass_loader,
  59. testRunner=grass_runner,
  60. exit=exit_at_end,
  61. verbosity=verbosity,
  62. failfast=failfast,
  63. catchbreak=catchbreak,
  64. buffer=buffer_stdout_stderr,
  65. )
  66. def test():
  67. """Run a test of a module."""
  68. # TODO: put the link to to the report only if available
  69. # TODO: how to disable Python code coverage for module and C tests?
  70. # TODO: we probably need to have different test functions for C, Python modules, and Python code
  71. # TODO: combine the results using python -m coverage --help | grep combine
  72. # TODO: function to anonymize/beautify file names (in content and actual filenames)
  73. # TODO: implement coverage but only when requested by invoker and only if
  74. # it makes sense for tests (need to know what is tested)
  75. # doing_coverage = False
  76. # try:
  77. # import coverage
  78. # doing_coverage = True
  79. # cov = coverage.coverage(omit="*testsuite*")
  80. # cov.start()
  81. # except ImportError:
  82. # pass
  83. # TODO: add some message somewhere
  84. # TODO: enable passing omit to exclude also gunittest or nothing
  85. program = GrassTestProgram(
  86. module="__main__", exit_at_end=False, grass_location="all"
  87. )
  88. # TODO: check if we are in the directory where the test file is
  89. # this will ensure that data directory is available when it is requested
  90. # if doing_coverage:
  91. # cov.stop()
  92. # cov.html_report(directory='testcodecoverage')
  93. # TODO: is sys.exit the right thing here
  94. sys.exit(not program.result.wasSuccessful())
  95. def discovery():
  96. """Recursively find all tests in testsuite directories and run them
  97. Everything is imported and runs in this process.
  98. Runs using::
  99. python main.py discovery [start_directory]
  100. """
  101. program = GrassTestProgram(grass_location="nc", exit_at_end=False)
  102. sys.exit(not program.result.wasSuccessful())
  103. CONFIG_FILENAME = ".gunittest.cfg"
  104. def get_config(start_directory, config_file):
  105. """Read configuration if available, return empty section proxy if not
  106. If file is explicitly specified, it must exist.
  107. Raises OSError if file is not accessible, e.g., if it exists,
  108. but there is an issue with permissions.
  109. """
  110. config_parser = configparser.ConfigParser()
  111. if config_file:
  112. with open(config_file, encoding="utf-8") as file:
  113. config_parser.read_file(file)
  114. elif start_directory:
  115. config_file = Path(start_directory) / CONFIG_FILENAME
  116. # Does not check presence of the file
  117. config_parser.read(config_file)
  118. else:
  119. raise ValueError("Either start_directory or config_file must be set")
  120. if "gunittest" not in config_parser:
  121. # Create an empty section if file is not available or section is not present.
  122. config_parser.read_dict({"gunittest": {}})
  123. return config_parser["gunittest"]
  124. def main():
  125. parser = argparse.ArgumentParser(
  126. description="Run test files in all testsuite directories starting"
  127. " from the current one"
  128. " (runs on active GRASS session)"
  129. )
  130. parser.add_argument(
  131. "--location",
  132. dest="location",
  133. action="store",
  134. help="Name of location where to perform test",
  135. required=True,
  136. )
  137. parser.add_argument(
  138. "--location-type",
  139. dest="location_type",
  140. action="store",
  141. default="nc",
  142. help="Type of tests which should be run" " (tag corresponding to location)",
  143. )
  144. parser.add_argument(
  145. "--grassdata",
  146. dest="gisdbase",
  147. action="store",
  148. default=None,
  149. help="GRASS data(base) (GISDBASE) directory" " (current GISDBASE by default)",
  150. )
  151. parser.add_argument(
  152. "--output",
  153. dest="output",
  154. action="store",
  155. default="testreport",
  156. help="Output directory",
  157. )
  158. parser.add_argument(
  159. "--min-success",
  160. dest="min_success",
  161. action="store",
  162. default="100",
  163. type=int,
  164. help=(
  165. "Minimum success percentage (lower percentage"
  166. " than this will result in a non-zero return code; values 0-100)"
  167. ),
  168. )
  169. parser.add_argument(
  170. "--config",
  171. dest="config",
  172. action="store",
  173. type=str,
  174. help=f"Path to a configuration file (default: {CONFIG_FILENAME})",
  175. )
  176. args = parser.parse_args()
  177. gisdbase = args.gisdbase
  178. if gisdbase is None:
  179. # here we already rely on being in GRASS session
  180. gisdbase = gs.gisenv()["GISDBASE"]
  181. location = args.location
  182. location_type = args.location_type
  183. if not gisdbase:
  184. return "GISDBASE (grassdata directory) cannot be empty string\n"
  185. if not os.path.exists(gisdbase):
  186. return f"GISDBASE (grassdata directory) <{gisdbase}> does not exist\n"
  187. if not os.path.exists(os.path.join(gisdbase, location)):
  188. return (
  189. f"GRASS Location <{location}>"
  190. f" does not exist in GRASS Database <{gisdbase}>\n"
  191. )
  192. results_dir = args.output
  193. silent_rmtree(results_dir) # TODO: too brute force?
  194. start_dir = "."
  195. abs_start_dir = os.path.abspath(start_dir)
  196. try:
  197. config = get_config(start_directory=start_dir, config_file=args.config)
  198. except OSError as error:
  199. return f"Error reading configuration: {error}"
  200. invoker = GrassTestFilesInvoker(
  201. start_dir=start_dir,
  202. file_anonymizer=FileAnonymizer(paths_to_remove=[abs_start_dir]),
  203. timeout=config.getfloat("timeout", None),
  204. )
  205. # TODO: remove also results dir from files
  206. # as an enhancement
  207. # we can just iterate over all locations available in database
  208. # but the we don't know the right location type (category, label, shortcut)
  209. reporter = invoker.run_in_location(
  210. gisdbase=gisdbase,
  211. location=location,
  212. location_type=location_type,
  213. results_dir=results_dir,
  214. exclude=config.get("exclude", "").split(),
  215. )
  216. if not reporter.test_files:
  217. return "No tests found or executed"
  218. if reporter.file_pass_per >= args.min_success:
  219. return 0
  220. return 1
  221. if __name__ == "__main__":
  222. sys.exit(main())