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 *,
261 indent: typing.Union[str, int] = "",
262 base_1: bool = True,
263 ) -> str:
264 """
265 Helpful method for debugging a parser - prints a string with line and column numbers.
266 (Line and column numbers are 1-based by default - if debugging a parse action,
267 pass base_1=False, to correspond to the loc value passed to the parse action.)
268
269 :param s: tuple(bool, str - string to be printed with line and column numbers
270 :param start_line: int - (optional) starting line number in s to print (default=1)
271 :param end_line: int - (optional) ending line number in s to print (default=len(s))
272 :param expand_tabs: bool - (optional) expand tabs to spaces, to match the pyparsing default
273 :param eol_mark: str - (optional) string to mark the end of lines, helps visualize trailing spaces (default="|")
274 :param mark_spaces: str - (optional) special character to display in place of spaces
275 :param mark_control: str - (optional) convert non-printing control characters to a placeholding
276 character; valid values:
277 - "unicode" - replaces control chars with Unicode symbols, such as "␍" and "␊"
278 - any single character string - replace control characters with given string
279 - None (default) - string is displayed as-is
280 :param indent: str | int - (optional) string to indent with line and column numbers; if an int
281 is passed, converted to " " * indent
282 :param base_1: bool - (optional) whether to label string using base 1; if False, string will be
283 labeled based at 0 (default=True)
284
285 :return: str - input string with leading line numbers and column number headers
286 """
287 if expand_tabs:
288 s = s.expandtabs()
289 if isinstance(indent, int):
290 indent = " " * indent
291 indent = indent.expandtabs()
292 if mark_control is not None:
293 mark_control = typing.cast(str, mark_control)
294 if mark_control == "unicode":
295 transtable_map = {
296 c: u for c, u in zip(range(0, 33), range(0x2400, 0x2433))
297 }
298 transtable_map[127] = 0x2421
299 tbl = str.maketrans(transtable_map)
300 eol_mark = ""
301 else:
302 ord_mark_control = ord(mark_control)
303 tbl = str.maketrans(
304 {c: ord_mark_control for c in list(range(0, 32)) + [127]}
305 )
306 s = s.translate(tbl)
307 if mark_spaces is not None and mark_spaces != " ":
308 if mark_spaces == "unicode":
309 tbl = str.maketrans({9: 0x2409, 32: 0x2423})
310 s = s.translate(tbl)
311 else:
312 s = s.replace(" ", mark_spaces)
313 if start_line is None:
314 start_line = 0
315 if end_line is None:
316 end_line = len(s)
317 end_line = min(end_line, len(s))
318 start_line = min(max(0, start_line), end_line)
319
320 if mark_control != "unicode":
321 s_lines = s.splitlines()[start_line - base_1 : end_line]
322 else:
323 s_lines = [
324 line + "␊" for line in s.split("␊")[start_line - base_1 : end_line]
325 ]
326 if not s_lines:
327 return ""
328
329 lineno_width = len(str(end_line))
330 max_line_len = max(len(line) for line in s_lines)
331 lead = indent + " " * (lineno_width + 1)
332 if max_line_len >= 99:
333 header0 = (
334 lead
335 + ("" if base_1 else " ")
336 + "".join(
337 f"{' ' * 99}{(i + 1) % 100}"
338 for i in range(1 if base_1 else 0, max(max_line_len // 100, 1))
339 )
340 + "\n"
341 )
342 else:
343 header0 = ""
344 header1 = (
345 ("" if base_1 else " ")
346 + lead
347 + "".join(f" {(i + 1) % 10}" for i in range(-(-max_line_len // 10)))
348 + "\n"
349 )
350 digits = "1234567890"
351 header2 = (
352 lead + ("" if base_1 else "0") + digits * (-(-max_line_len // 10)) + "\n"
353 )
354 return (
355 header1
356 + header2
357 + "\n".join(
358 f"{indent}{i:{lineno_width}d}:{line}{eol_mark}"
359 for i, line in enumerate(s_lines, start=start_line + base_1)
360 )
361 + "\n"
362 )