testing.py 15 KB

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