]>
Commit | Line | Data |
---|---|---|
e0df8241 JR |
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(f"no validation for {test_string!r}") | |
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 | mark_control = typing.cast(str, mark_control) | |
269 | if mark_control == "unicode": | |
270 | transtable_map = { | |
271 | c: u for c, u in zip(range(0, 33), range(0x2400, 0x2433)) | |
272 | } | |
273 | transtable_map[127] = 0x2421 | |
274 | tbl = str.maketrans(transtable_map) | |
275 | eol_mark = "" | |
276 | else: | |
277 | ord_mark_control = ord(mark_control) | |
278 | tbl = str.maketrans( | |
279 | {c: ord_mark_control for c in list(range(0, 32)) + [127]} | |
280 | ) | |
281 | s = s.translate(tbl) | |
282 | if mark_spaces is not None and mark_spaces != " ": | |
283 | if mark_spaces == "unicode": | |
284 | tbl = str.maketrans({9: 0x2409, 32: 0x2423}) | |
285 | s = s.translate(tbl) | |
286 | else: | |
287 | s = s.replace(" ", mark_spaces) | |
288 | if start_line is None: | |
289 | start_line = 1 | |
290 | if end_line is None: | |
291 | end_line = len(s) | |
292 | end_line = min(end_line, len(s)) | |
293 | start_line = min(max(1, start_line), end_line) | |
294 | ||
295 | if mark_control != "unicode": | |
296 | s_lines = s.splitlines()[start_line - 1 : end_line] | |
297 | else: | |
298 | s_lines = [line + "␊" for line in s.split("␊")[start_line - 1 : end_line]] | |
299 | if not s_lines: | |
300 | return "" | |
301 | ||
302 | lineno_width = len(str(end_line)) | |
303 | max_line_len = max(len(line) for line in s_lines) | |
304 | lead = " " * (lineno_width + 1) | |
305 | if max_line_len >= 99: | |
306 | header0 = ( | |
307 | lead | |
308 | + "".join( | |
309 | f"{' ' * 99}{(i + 1) % 100}" | |
310 | for i in range(max(max_line_len // 100, 1)) | |
311 | ) | |
312 | + "\n" | |
313 | ) | |
314 | else: | |
315 | header0 = "" | |
316 | header1 = ( | |
317 | header0 | |
318 | + lead | |
319 | + "".join(f" {(i + 1) % 10}" for i in range(-(-max_line_len // 10))) | |
320 | + "\n" | |
321 | ) | |
322 | header2 = lead + "1234567890" * (-(-max_line_len // 10)) + "\n" | |
323 | return ( | |
324 | header1 | |
325 | + header2 | |
326 | + "\n".join( | |
327 | f"{i:{lineno_width}d}:{line}{eol_mark}" | |
328 | for i, line in enumerate(s_lines, start=start_line) | |
329 | ) | |
330 | + "\n" | |
331 | ) |