spirv_test_framework.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. # Copyright (c) 2018 Google LLC
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. """Manages and runs tests from the current working directory.
  15. This will traverse the current working directory and look for python files that
  16. contain subclasses of SpirvTest.
  17. If a class has an @inside_spirv_testsuite decorator, an instance of that
  18. class will be created and serve as a test case in that testsuite. The test
  19. case is then run by the following steps:
  20. 1. A temporary directory will be created.
  21. 2. The spirv_args member variable will be inspected and all placeholders in it
  22. will be expanded by calling instantiate_for_spirv_args() on placeholders.
  23. The transformed list elements are then supplied as arguments to the spirv-*
  24. tool under test.
  25. 3. If the environment member variable exists, its write() method will be
  26. invoked.
  27. 4. All expected_* member variables will be inspected and all placeholders in
  28. them will be expanded by calling instantiate_for_expectation() on those
  29. placeholders. After placeholder expansion, if the expected_* variable is
  30. a list, its element will be joined together with '' to form a single
  31. string. These expected_* variables are to be used by the check_*() methods.
  32. 5. The spirv-* tool will be run with the arguments supplied in spirv_args.
  33. 6. All check_*() member methods will be called by supplying a TestStatus as
  34. argument. Each check_*() method is expected to return a (Success, Message)
  35. pair where Success is a boolean indicating success and Message is an error
  36. message.
  37. 7. If any check_*() method fails, the error message is output and the
  38. current test case fails.
  39. If --leave-output was not specified, all temporary files and directories will
  40. be deleted.
  41. """
  42. import argparse
  43. import fnmatch
  44. import inspect
  45. import os
  46. import shutil
  47. import subprocess
  48. import sys
  49. import tempfile
  50. from collections import defaultdict
  51. from placeholder import PlaceHolder
  52. EXPECTED_BEHAVIOR_PREFIX = 'expected_'
  53. VALIDATE_METHOD_PREFIX = 'check_'
  54. def get_all_variables(instance):
  55. """Returns the names of all the variables in instance."""
  56. return [v for v in dir(instance) if not callable(getattr(instance, v))]
  57. def get_all_methods(instance):
  58. """Returns the names of all methods in instance."""
  59. return [m for m in dir(instance) if callable(getattr(instance, m))]
  60. def get_all_superclasses(cls):
  61. """Returns all superclasses of a given class. Omits root 'object' superclass.
  62. Returns:
  63. A list of superclasses of the given class. The order guarantees that
  64. * A Base class precedes its derived classes, e.g., for "class B(A)", it
  65. will be [..., A, B, ...].
  66. * When there are multiple base classes, base classes declared first
  67. precede those declared later, e.g., for "class C(A, B), it will be
  68. [..., A, B, C, ...]
  69. """
  70. classes = []
  71. for superclass in cls.__bases__:
  72. for c in get_all_superclasses(superclass):
  73. if c is not object and c not in classes:
  74. classes.append(c)
  75. for superclass in cls.__bases__:
  76. if superclass is not object and superclass not in classes:
  77. classes.append(superclass)
  78. return classes
  79. def get_all_test_methods(test_class):
  80. """Gets all validation methods.
  81. Returns:
  82. A list of validation methods. The order guarantees that
  83. * A method defined in superclass precedes one defined in subclass,
  84. e.g., for "class A(B)", methods defined in B precedes those defined
  85. in A.
  86. * If a subclass has more than one superclass, e.g., "class C(A, B)",
  87. then methods defined in A precedes those defined in B.
  88. """
  89. classes = get_all_superclasses(test_class)
  90. classes.append(test_class)
  91. all_tests = [
  92. m for c in classes for m in get_all_methods(c)
  93. if m.startswith(VALIDATE_METHOD_PREFIX)
  94. ]
  95. unique_tests = []
  96. for t in all_tests:
  97. if t not in unique_tests:
  98. unique_tests.append(t)
  99. return unique_tests
  100. class SpirvTest:
  101. """Base class for spirv test cases.
  102. Subclasses define test cases' facts (shader source code, spirv command,
  103. result validation), which will be used by the TestCase class for running
  104. tests. Subclasses should define spirv_args (specifying spirv_tool command
  105. arguments), and at least one check_*() method (for result validation) for
  106. a full-fledged test case. All check_*() methods should take a TestStatus
  107. parameter and return a (Success, Message) pair, in which Success is a
  108. boolean indicating success and Message is an error message. The test passes
  109. iff all check_*() methods returns true.
  110. Often, a test case class will delegate the check_* behaviors by inheriting
  111. from other classes.
  112. """
  113. def name(self):
  114. return self.__class__.__name__
  115. class TestStatus:
  116. """A struct for holding run status of a test case."""
  117. def __init__(self, test_manager, returncode, stdout, stderr, directory,
  118. inputs, input_filenames):
  119. self.test_manager = test_manager
  120. self.returncode = returncode
  121. # Some of our MacOS bots still run Python 2, so need to be backwards
  122. # compatible here.
  123. if type(stdout) is not str:
  124. if sys.version_info[0] == 2:
  125. self.stdout = stdout.decode('utf-8')
  126. elif sys.version_info[0] == 3:
  127. self.stdout = str(stdout, encoding='utf-8') if stdout is not None else stdout
  128. else:
  129. raise Exception('Unable to determine if running Python 2 or 3 from {}'.format(sys.version_info))
  130. else:
  131. self.stdout = stdout
  132. if type(stderr) is not str:
  133. if sys.version_info[0] == 2:
  134. self.stderr = stderr.decode('utf-8')
  135. elif sys.version_info[0] == 3:
  136. self.stderr = str(stderr, encoding='utf-8') if stderr is not None else stderr
  137. else:
  138. raise Exception('Unable to determine if running Python 2 or 3 from {}'.format(sys.version_info))
  139. else:
  140. self.stderr = stderr
  141. # temporary directory where the test runs
  142. self.directory = directory
  143. # List of inputs, as PlaceHolder objects.
  144. self.inputs = inputs
  145. # the names of input shader files (potentially including paths)
  146. self.input_filenames = input_filenames
  147. class SpirvTestException(Exception):
  148. """SpirvTest exception class."""
  149. pass
  150. def inside_spirv_testsuite(testsuite_name):
  151. """Decorator for subclasses of SpirvTest.
  152. This decorator checks that a class meets the requirements (see below)
  153. for a test case class, and then puts the class in a certain testsuite.
  154. * The class needs to be a subclass of SpirvTest.
  155. * The class needs to have spirv_args defined as a list.
  156. * The class needs to define at least one check_*() methods.
  157. * All expected_* variables required by check_*() methods can only be
  158. of bool, str, or list type.
  159. * Python runtime will throw an exception if the expected_* member
  160. attributes required by check_*() methods are missing.
  161. """
  162. def actual_decorator(cls):
  163. if not inspect.isclass(cls):
  164. raise SpirvTestException('Test case should be a class')
  165. if not issubclass(cls, SpirvTest):
  166. raise SpirvTestException(
  167. 'All test cases should be subclasses of SpirvTest')
  168. if 'spirv_args' not in get_all_variables(cls):
  169. raise SpirvTestException('No spirv_args found in the test case')
  170. if not isinstance(cls.spirv_args, list):
  171. raise SpirvTestException('spirv_args needs to be a list')
  172. if not any(
  173. [m.startswith(VALIDATE_METHOD_PREFIX) for m in get_all_methods(cls)]):
  174. raise SpirvTestException('No check_*() methods found in the test case')
  175. if not all(
  176. [isinstance(v, (bool, str, list)) for v in get_all_variables(cls)]):
  177. raise SpirvTestException(
  178. 'expected_* variables are only allowed to be bool, str, or '
  179. 'list type.')
  180. cls.parent_testsuite = testsuite_name
  181. return cls
  182. return actual_decorator
  183. class TestManager:
  184. """Manages and runs a set of tests."""
  185. def __init__(self, executable_path, assembler_path, disassembler_path):
  186. self.executable_path = executable_path
  187. self.assembler_path = assembler_path
  188. self.disassembler_path = disassembler_path
  189. self.num_successes = 0
  190. self.num_failures = 0
  191. self.num_tests = 0
  192. self.leave_output = False
  193. self.tests = defaultdict(list)
  194. def notify_result(self, test_case, success, message):
  195. """Call this to notify the manager of the results of a test run."""
  196. self.num_successes += 1 if success else 0
  197. self.num_failures += 0 if success else 1
  198. counter_string = str(self.num_successes + self.num_failures) + '/' + str(
  199. self.num_tests)
  200. print('%-10s %-40s ' % (counter_string, test_case.test.name()) +
  201. ('Passed' if success else '-Failed-'))
  202. if not success:
  203. print(' '.join(test_case.command))
  204. print(message)
  205. def add_test(self, testsuite, test):
  206. """Add this to the current list of test cases."""
  207. self.tests[testsuite].append(TestCase(test, self))
  208. self.num_tests += 1
  209. def run_tests(self):
  210. for suite in self.tests:
  211. print('SPIRV tool test suite: "{suite}"'.format(suite=suite))
  212. for x in self.tests[suite]:
  213. x.runTest()
  214. class TestCase:
  215. """A single test case that runs in its own directory."""
  216. def __init__(self, test, test_manager):
  217. self.test = test
  218. self.test_manager = test_manager
  219. self.inputs = [] # inputs, as PlaceHolder objects.
  220. self.file_shaders = [] # filenames of shader files.
  221. self.stdin_shader = None # text to be passed to spirv_tool as stdin
  222. def setUp(self):
  223. """Creates environment and instantiates placeholders for the test case."""
  224. self.directory = tempfile.mkdtemp(dir=os.getcwd())
  225. spirv_args = self.test.spirv_args
  226. # Instantiate placeholders in spirv_args
  227. self.test.spirv_args = [
  228. arg.instantiate_for_spirv_args(self)
  229. if isinstance(arg, PlaceHolder) else arg for arg in self.test.spirv_args
  230. ]
  231. # Get all shader files' names
  232. self.inputs = [arg for arg in spirv_args if isinstance(arg, PlaceHolder)]
  233. self.file_shaders = [arg.filename for arg in self.inputs]
  234. if 'environment' in get_all_variables(self.test):
  235. self.test.environment.write(self.directory)
  236. expectations = [
  237. v for v in get_all_variables(self.test)
  238. if v.startswith(EXPECTED_BEHAVIOR_PREFIX)
  239. ]
  240. # Instantiate placeholders in expectations
  241. for expectation_name in expectations:
  242. expectation = getattr(self.test, expectation_name)
  243. if isinstance(expectation, list):
  244. expanded_expections = [
  245. element.instantiate_for_expectation(self)
  246. if isinstance(element, PlaceHolder) else element
  247. for element in expectation
  248. ]
  249. setattr(self.test, expectation_name, expanded_expections)
  250. elif isinstance(expectation, PlaceHolder):
  251. setattr(self.test, expectation_name,
  252. expectation.instantiate_for_expectation(self))
  253. def tearDown(self):
  254. """Removes the directory if we were not instructed to do otherwise."""
  255. if not self.test_manager.leave_output:
  256. shutil.rmtree(self.directory)
  257. def runTest(self):
  258. """Sets up and runs a test, reports any failures and then cleans up."""
  259. self.setUp()
  260. success = False
  261. message = ''
  262. try:
  263. self.command = [self.test_manager.executable_path]
  264. self.command.extend(self.test.spirv_args)
  265. process = subprocess.Popen(
  266. args=self.command,
  267. stdin=subprocess.PIPE,
  268. stdout=subprocess.PIPE,
  269. stderr=subprocess.PIPE,
  270. cwd=self.directory)
  271. output = process.communicate(self.stdin_shader)
  272. test_status = TestStatus(self.test_manager, process.returncode, output[0],
  273. output[1], self.directory, self.inputs,
  274. self.file_shaders)
  275. run_results = [
  276. getattr(self.test, test_method)(test_status)
  277. for test_method in get_all_test_methods(self.test.__class__)
  278. ]
  279. success, message = zip(*run_results)
  280. success = all(success)
  281. message = '\n'.join(message)
  282. except Exception as e:
  283. success = False
  284. message = str(e)
  285. self.test_manager.notify_result(
  286. self, success,
  287. message + '\nSTDOUT:\n%s\nSTDERR:\n%s' % (output[0], output[1]))
  288. self.tearDown()
  289. def main():
  290. parser = argparse.ArgumentParser()
  291. parser.add_argument(
  292. 'spirv_tool',
  293. metavar='path/to/spirv_tool',
  294. type=str,
  295. nargs=1,
  296. help='Path to the spirv-* tool under test')
  297. parser.add_argument(
  298. 'spirv_as',
  299. metavar='path/to/spirv-as',
  300. type=str,
  301. nargs=1,
  302. help='Path to spirv-as')
  303. parser.add_argument(
  304. 'spirv_dis',
  305. metavar='path/to/spirv-dis',
  306. type=str,
  307. nargs=1,
  308. help='Path to spirv-dis')
  309. parser.add_argument(
  310. '--leave-output',
  311. action='store_const',
  312. const=1,
  313. help='Do not clean up temporary directories')
  314. parser.add_argument(
  315. '--test-dir', nargs=1, help='Directory to gather the tests from')
  316. args = parser.parse_args()
  317. default_path = sys.path
  318. root_dir = os.getcwd()
  319. if args.test_dir:
  320. root_dir = args.test_dir[0]
  321. manager = TestManager(args.spirv_tool[0], args.spirv_as[0], args.spirv_dis[0])
  322. if args.leave_output:
  323. manager.leave_output = True
  324. for root, _, filenames in os.walk(root_dir):
  325. for filename in fnmatch.filter(filenames, '*.py'):
  326. if filename.endswith('nosetest.py'):
  327. # Skip nose tests, which are for testing functions of
  328. # the test framework.
  329. continue
  330. sys.path = default_path
  331. sys.path.append(root)
  332. mod = __import__(os.path.splitext(filename)[0])
  333. for _, obj, in inspect.getmembers(mod):
  334. if inspect.isclass(obj) and hasattr(obj, 'parent_testsuite'):
  335. manager.add_test(obj.parent_testsuite, obj())
  336. manager.run_tests()
  337. if manager.num_failures > 0:
  338. sys.exit(-1)
  339. if __name__ == '__main__':
  340. main()