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