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