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