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