testing.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. # testing.py
  2. from contextlib import contextmanager
  3. from typing import Optional
  4. from .core import (
  5. ParserElement,
  6. ParseException,
  7. Keyword,
  8. __diag__,
  9. __compat__,
  10. )
  11. class pyparsing_test:
  12. """
  13. namespace class for classes useful in writing unit tests
  14. """
  15. class reset_pyparsing_context:
  16. """
  17. Context manager to be used when writing unit tests that modify pyparsing config values:
  18. - packrat parsing
  19. - bounded recursion parsing
  20. - default whitespace characters.
  21. - default keyword characters
  22. - literal string auto-conversion class
  23. - __diag__ settings
  24. Example::
  25. with reset_pyparsing_context():
  26. # test that literals used to construct a grammar are automatically suppressed
  27. ParserElement.inlineLiteralsUsing(Suppress)
  28. term = Word(alphas) | Word(nums)
  29. group = Group('(' + term[...] + ')')
  30. # assert that the '()' characters are not included in the parsed tokens
  31. self.assertParseAndCheckList(group, "(abc 123 def)", ['abc', '123', 'def'])
  32. # after exiting context manager, literals are converted to Literal expressions again
  33. """
  34. def __init__(self):
  35. self._save_context = {}
  36. def save(self):
  37. self._save_context["default_whitespace"] = ParserElement.DEFAULT_WHITE_CHARS
  38. self._save_context["default_keyword_chars"] = Keyword.DEFAULT_KEYWORD_CHARS
  39. self._save_context[
  40. "literal_string_class"
  41. ] = ParserElement._literalStringClass
  42. self._save_context["verbose_stacktrace"] = ParserElement.verbose_stacktrace
  43. self._save_context["packrat_enabled"] = ParserElement._packratEnabled
  44. if ParserElement._packratEnabled:
  45. self._save_context[
  46. "packrat_cache_size"
  47. ] = ParserElement.packrat_cache.size
  48. else:
  49. self._save_context["packrat_cache_size"] = None
  50. self._save_context["packrat_parse"] = ParserElement._parse
  51. self._save_context[
  52. "recursion_enabled"
  53. ] = ParserElement._left_recursion_enabled
  54. self._save_context["__diag__"] = {
  55. name: getattr(__diag__, name) for name in __diag__._all_names
  56. }
  57. self._save_context["__compat__"] = {
  58. "collect_all_And_tokens": __compat__.collect_all_And_tokens
  59. }
  60. return self
  61. def restore(self):
  62. # reset pyparsing global state
  63. if (
  64. ParserElement.DEFAULT_WHITE_CHARS
  65. != self._save_context["default_whitespace"]
  66. ):
  67. ParserElement.set_default_whitespace_chars(
  68. self._save_context["default_whitespace"]
  69. )
  70. ParserElement.verbose_stacktrace = self._save_context["verbose_stacktrace"]
  71. Keyword.DEFAULT_KEYWORD_CHARS = self._save_context["default_keyword_chars"]
  72. ParserElement.inlineLiteralsUsing(
  73. self._save_context["literal_string_class"]
  74. )
  75. for name, value in self._save_context["__diag__"].items():
  76. (__diag__.enable if value else __diag__.disable)(name)
  77. ParserElement._packratEnabled = False
  78. if self._save_context["packrat_enabled"]:
  79. ParserElement.enable_packrat(self._save_context["packrat_cache_size"])
  80. else:
  81. ParserElement._parse = self._save_context["packrat_parse"]
  82. ParserElement._left_recursion_enabled = self._save_context[
  83. "recursion_enabled"
  84. ]
  85. __compat__.collect_all_And_tokens = self._save_context["__compat__"]
  86. return self
  87. def copy(self):
  88. ret = type(self)()
  89. ret._save_context.update(self._save_context)
  90. return ret
  91. def __enter__(self):
  92. return self.save()
  93. def __exit__(self, *args):
  94. self.restore()
  95. class TestParseResultsAsserts:
  96. """
  97. A mixin class to add parse results assertion methods to normal unittest.TestCase classes.
  98. """
  99. def assertParseResultsEquals(
  100. self, result, expected_list=None, expected_dict=None, msg=None
  101. ):
  102. """
  103. Unit test assertion to compare a :class:`ParseResults` object with an optional ``expected_list``,
  104. and compare any defined results names with an optional ``expected_dict``.
  105. """
  106. if expected_list is not None:
  107. self.assertEqual(expected_list, result.as_list(), msg=msg)
  108. if expected_dict is not None:
  109. self.assertEqual(expected_dict, result.as_dict(), msg=msg)
  110. def assertParseAndCheckList(
  111. self, expr, test_string, expected_list, msg=None, verbose=True
  112. ):
  113. """
  114. Convenience wrapper assert to test a parser element and input string, and assert that
  115. the resulting ``ParseResults.asList()`` is equal to the ``expected_list``.
  116. """
  117. result = expr.parse_string(test_string, parse_all=True)
  118. if verbose:
  119. print(result.dump())
  120. else:
  121. print(result.as_list())
  122. self.assertParseResultsEquals(result, expected_list=expected_list, msg=msg)
  123. def assertParseAndCheckDict(
  124. self, expr, test_string, expected_dict, msg=None, verbose=True
  125. ):
  126. """
  127. Convenience wrapper assert to test a parser element and input string, and assert that
  128. the resulting ``ParseResults.asDict()`` is equal to the ``expected_dict``.
  129. """
  130. result = expr.parse_string(test_string, parseAll=True)
  131. if verbose:
  132. print(result.dump())
  133. else:
  134. print(result.as_list())
  135. self.assertParseResultsEquals(result, expected_dict=expected_dict, msg=msg)
  136. def assertRunTestResults(
  137. self, run_tests_report, expected_parse_results=None, msg=None
  138. ):
  139. """
  140. Unit test assertion to evaluate output of ``ParserElement.runTests()``. If a list of
  141. list-dict tuples is given as the ``expected_parse_results`` argument, then these are zipped
  142. with the report tuples returned by ``runTests`` and evaluated using ``assertParseResultsEquals``.
  143. Finally, asserts that the overall ``runTests()`` success value is ``True``.
  144. :param run_tests_report: tuple(bool, [tuple(str, ParseResults or Exception)]) returned from runTests
  145. :param expected_parse_results (optional): [tuple(str, list, dict, Exception)]
  146. """
  147. run_test_success, run_test_results = run_tests_report
  148. if expected_parse_results is not None:
  149. merged = [
  150. (*rpt, expected)
  151. for rpt, expected in zip(run_test_results, expected_parse_results)
  152. ]
  153. for test_string, result, expected in merged:
  154. # expected should be a tuple containing a list and/or a dict or an exception,
  155. # and optional failure message string
  156. # an empty tuple will skip any result validation
  157. fail_msg = next(
  158. (exp for exp in expected if isinstance(exp, str)), None
  159. )
  160. expected_exception = next(
  161. (
  162. exp
  163. for exp in expected
  164. if isinstance(exp, type) and issubclass(exp, Exception)
  165. ),
  166. None,
  167. )
  168. if expected_exception is not None:
  169. with self.assertRaises(
  170. expected_exception=expected_exception, msg=fail_msg or msg
  171. ):
  172. if isinstance(result, Exception):
  173. raise result
  174. else:
  175. expected_list = next(
  176. (exp for exp in expected if isinstance(exp, list)), None
  177. )
  178. expected_dict = next(
  179. (exp for exp in expected if isinstance(exp, dict)), None
  180. )
  181. if (expected_list, expected_dict) != (None, None):
  182. self.assertParseResultsEquals(
  183. result,
  184. expected_list=expected_list,
  185. expected_dict=expected_dict,
  186. msg=fail_msg or msg,
  187. )
  188. else:
  189. # warning here maybe?
  190. print("no validation for {!r}".format(test_string))
  191. # do this last, in case some specific test results can be reported instead
  192. self.assertTrue(
  193. run_test_success, msg=msg if msg is not None else "failed runTests"
  194. )
  195. @contextmanager
  196. def assertRaisesParseException(self, exc_type=ParseException, msg=None):
  197. with self.assertRaises(exc_type, msg=msg):
  198. yield
  199. @staticmethod
  200. def with_line_numbers(
  201. s: str,
  202. start_line: Optional[int] = None,
  203. end_line: Optional[int] = None,
  204. expand_tabs: bool = True,
  205. eol_mark: str = "|",
  206. mark_spaces: Optional[str] = None,
  207. mark_control: Optional[str] = None,
  208. ) -> str:
  209. """
  210. Helpful method for debugging a parser - prints a string with line and column numbers.
  211. (Line and column numbers are 1-based.)
  212. :param s: tuple(bool, str - string to be printed with line and column numbers
  213. :param start_line: int - (optional) starting line number in s to print (default=1)
  214. :param end_line: int - (optional) ending line number in s to print (default=len(s))
  215. :param expand_tabs: bool - (optional) expand tabs to spaces, to match the pyparsing default
  216. :param eol_mark: str - (optional) string to mark the end of lines, helps visualize trailing spaces (default="|")
  217. :param mark_spaces: str - (optional) special character to display in place of spaces
  218. :param mark_control: str - (optional) convert non-printing control characters to a placeholding
  219. character; valid values:
  220. - "unicode" - replaces control chars with Unicode symbols, such as "␍" and "␊"
  221. - any single character string - replace control characters with given string
  222. - None (default) - string is displayed as-is
  223. :return: str - input string with leading line numbers and column number headers
  224. """
  225. if expand_tabs:
  226. s = s.expandtabs()
  227. if mark_control is not None:
  228. if mark_control == "unicode":
  229. tbl = str.maketrans(
  230. {c: u for c, u in zip(range(0, 33), range(0x2400, 0x2433))}
  231. | {127: 0x2421}
  232. )
  233. eol_mark = ""
  234. else:
  235. tbl = str.maketrans(
  236. {c: mark_control for c in list(range(0, 32)) + [127]}
  237. )
  238. s = s.translate(tbl)
  239. if mark_spaces is not None and mark_spaces != " ":
  240. if mark_spaces == "unicode":
  241. tbl = str.maketrans({9: 0x2409, 32: 0x2423})
  242. s = s.translate(tbl)
  243. else:
  244. s = s.replace(" ", mark_spaces)
  245. if start_line is None:
  246. start_line = 1
  247. if end_line is None:
  248. end_line = len(s)
  249. end_line = min(end_line, len(s))
  250. start_line = min(max(1, start_line), end_line)
  251. if mark_control != "unicode":
  252. s_lines = s.splitlines()[start_line - 1 : end_line]
  253. else:
  254. s_lines = [line + "␊" for line in s.split("␊")[start_line - 1 : end_line]]
  255. if not s_lines:
  256. return ""
  257. lineno_width = len(str(end_line))
  258. max_line_len = max(len(line) for line in s_lines)
  259. lead = " " * (lineno_width + 1)
  260. if max_line_len >= 99:
  261. header0 = (
  262. lead
  263. + "".join(
  264. "{}{}".format(" " * 99, (i + 1) % 100)
  265. for i in range(max(max_line_len // 100, 1))
  266. )
  267. + "\n"
  268. )
  269. else:
  270. header0 = ""
  271. header1 = (
  272. header0
  273. + lead
  274. + "".join(
  275. " {}".format((i + 1) % 10)
  276. for i in range(-(-max_line_len // 10))
  277. )
  278. + "\n"
  279. )
  280. header2 = lead + "1234567890" * (-(-max_line_len // 10)) + "\n"
  281. return (
  282. header1
  283. + header2
  284. + "\n".join(
  285. "{:{}d}:{}{}".format(i, lineno_width, line, eol_mark)
  286. for i, line in enumerate(s_lines, start=start_line)
  287. )
  288. + "\n"
  289. )