| # testing.py |
| |
| from contextlib import contextmanager |
| from typing import Optional |
| |
| from .core import ( |
| ParserElement, |
| ParseException, |
| Keyword, |
| __diag__, |
| __compat__, |
| ) |
| |
| |
| class pyparsing_test: |
| """ |
| namespace class for classes useful in writing unit tests |
| """ |
| |
| class reset_pyparsing_context: |
| """ |
| Context manager to be used when writing unit tests that modify pyparsing config values: |
| - packrat parsing |
| - bounded recursion parsing |
| - default whitespace characters. |
| - default keyword characters |
| - literal string auto-conversion class |
| - __diag__ settings |
| |
| Example:: |
| |
| with reset_pyparsing_context(): |
| # test that literals used to construct a grammar are automatically suppressed |
| ParserElement.inlineLiteralsUsing(Suppress) |
| |
| term = Word(alphas) | Word(nums) |
| group = Group('(' + term[...] + ')') |
| |
| # assert that the '()' characters are not included in the parsed tokens |
| self.assertParseAndCheckList(group, "(abc 123 def)", ['abc', '123', 'def']) |
| |
| # after exiting context manager, literals are converted to Literal expressions again |
| """ |
| |
| def __init__(self): |
| self._save_context = {} |
| |
| def save(self): |
| self._save_context["default_whitespace"] = ParserElement.DEFAULT_WHITE_CHARS |
| self._save_context["default_keyword_chars"] = Keyword.DEFAULT_KEYWORD_CHARS |
| |
| self._save_context[ |
| "literal_string_class" |
| ] = ParserElement._literalStringClass |
| |
| self._save_context["verbose_stacktrace"] = ParserElement.verbose_stacktrace |
| |
| self._save_context["packrat_enabled"] = ParserElement._packratEnabled |
| if ParserElement._packratEnabled: |
| self._save_context[ |
| "packrat_cache_size" |
| ] = ParserElement.packrat_cache.size |
| else: |
| self._save_context["packrat_cache_size"] = None |
| self._save_context["packrat_parse"] = ParserElement._parse |
| self._save_context[ |
| "recursion_enabled" |
| ] = ParserElement._left_recursion_enabled |
| |
| self._save_context["__diag__"] = { |
| name: getattr(__diag__, name) for name in __diag__._all_names |
| } |
| |
| self._save_context["__compat__"] = { |
| "collect_all_And_tokens": __compat__.collect_all_And_tokens |
| } |
| |
| return self |
| |
| def restore(self): |
| # reset pyparsing global state |
| if ( |
| ParserElement.DEFAULT_WHITE_CHARS |
| != self._save_context["default_whitespace"] |
| ): |
| ParserElement.set_default_whitespace_chars( |
| self._save_context["default_whitespace"] |
| ) |
| |
| ParserElement.verbose_stacktrace = self._save_context["verbose_stacktrace"] |
| |
| Keyword.DEFAULT_KEYWORD_CHARS = self._save_context["default_keyword_chars"] |
| ParserElement.inlineLiteralsUsing( |
| self._save_context["literal_string_class"] |
| ) |
| |
| for name, value in self._save_context["__diag__"].items(): |
| (__diag__.enable if value else __diag__.disable)(name) |
| |
| ParserElement._packratEnabled = False |
| if self._save_context["packrat_enabled"]: |
| ParserElement.enable_packrat(self._save_context["packrat_cache_size"]) |
| else: |
| ParserElement._parse = self._save_context["packrat_parse"] |
| ParserElement._left_recursion_enabled = self._save_context[ |
| "recursion_enabled" |
| ] |
| |
| __compat__.collect_all_And_tokens = self._save_context["__compat__"] |
| |
| return self |
| |
| def copy(self): |
| ret = type(self)() |
| ret._save_context.update(self._save_context) |
| return ret |
| |
| def __enter__(self): |
| return self.save() |
| |
| def __exit__(self, *args): |
| self.restore() |
| |
| class TestParseResultsAsserts: |
| """ |
| A mixin class to add parse results assertion methods to normal unittest.TestCase classes. |
| """ |
| |
| def assertParseResultsEquals( |
| self, result, expected_list=None, expected_dict=None, msg=None |
| ): |
| """ |
| Unit test assertion to compare a :class:`ParseResults` object with an optional ``expected_list``, |
| and compare any defined results names with an optional ``expected_dict``. |
| """ |
| if expected_list is not None: |
| self.assertEqual(expected_list, result.as_list(), msg=msg) |
| if expected_dict is not None: |
| self.assertEqual(expected_dict, result.as_dict(), msg=msg) |
| |
| def assertParseAndCheckList( |
| self, expr, test_string, expected_list, msg=None, verbose=True |
| ): |
| """ |
| Convenience wrapper assert to test a parser element and input string, and assert that |
| the resulting ``ParseResults.asList()`` is equal to the ``expected_list``. |
| """ |
| result = expr.parse_string(test_string, parse_all=True) |
| if verbose: |
| print(result.dump()) |
| else: |
| print(result.as_list()) |
| self.assertParseResultsEquals(result, expected_list=expected_list, msg=msg) |
| |
| def assertParseAndCheckDict( |
| self, expr, test_string, expected_dict, msg=None, verbose=True |
| ): |
| """ |
| Convenience wrapper assert to test a parser element and input string, and assert that |
| the resulting ``ParseResults.asDict()`` is equal to the ``expected_dict``. |
| """ |
| result = expr.parse_string(test_string, parseAll=True) |
| if verbose: |
| print(result.dump()) |
| else: |
| print(result.as_list()) |
| self.assertParseResultsEquals(result, expected_dict=expected_dict, msg=msg) |
| |
| def assertRunTestResults( |
| self, run_tests_report, expected_parse_results=None, msg=None |
| ): |
| """ |
| Unit test assertion to evaluate output of ``ParserElement.runTests()``. If a list of |
| list-dict tuples is given as the ``expected_parse_results`` argument, then these are zipped |
| with the report tuples returned by ``runTests`` and evaluated using ``assertParseResultsEquals``. |
| Finally, asserts that the overall ``runTests()`` success value is ``True``. |
| |
| :param run_tests_report: tuple(bool, [tuple(str, ParseResults or Exception)]) returned from runTests |
| :param expected_parse_results (optional): [tuple(str, list, dict, Exception)] |
| """ |
| run_test_success, run_test_results = run_tests_report |
| |
| if expected_parse_results is not None: |
| merged = [ |
| (*rpt, expected) |
| for rpt, expected in zip(run_test_results, expected_parse_results) |
| ] |
| for test_string, result, expected in merged: |
| # expected should be a tuple containing a list and/or a dict or an exception, |
| # and optional failure message string |
| # an empty tuple will skip any result validation |
| fail_msg = next( |
| (exp for exp in expected if isinstance(exp, str)), None |
| ) |
| expected_exception = next( |
| ( |
| exp |
| for exp in expected |
| if isinstance(exp, type) and issubclass(exp, Exception) |
| ), |
| None, |
| ) |
| if expected_exception is not None: |
| with self.assertRaises( |
| expected_exception=expected_exception, msg=fail_msg or msg |
| ): |
| if isinstance(result, Exception): |
| raise result |
| else: |
| expected_list = next( |
| (exp for exp in expected if isinstance(exp, list)), None |
| ) |
| expected_dict = next( |
| (exp for exp in expected if isinstance(exp, dict)), None |
| ) |
| if (expected_list, expected_dict) != (None, None): |
| self.assertParseResultsEquals( |
| result, |
| expected_list=expected_list, |
| expected_dict=expected_dict, |
| msg=fail_msg or msg, |
| ) |
| else: |
| # warning here maybe? |
| print("no validation for {!r}".format(test_string)) |
| |
| # do this last, in case some specific test results can be reported instead |
| self.assertTrue( |
| run_test_success, msg=msg if msg is not None else "failed runTests" |
| ) |
| |
| @contextmanager |
| def assertRaisesParseException(self, exc_type=ParseException, msg=None): |
| with self.assertRaises(exc_type, msg=msg): |
| yield |
| |
| @staticmethod |
| def with_line_numbers( |
| s: str, |
| start_line: Optional[int] = None, |
| end_line: Optional[int] = None, |
| expand_tabs: bool = True, |
| eol_mark: str = "|", |
| mark_spaces: Optional[str] = None, |
| mark_control: Optional[str] = None, |
| ) -> str: |
| """ |
| Helpful method for debugging a parser - prints a string with line and column numbers. |
| (Line and column numbers are 1-based.) |
| |
| :param s: tuple(bool, str - string to be printed with line and column numbers |
| :param start_line: int - (optional) starting line number in s to print (default=1) |
| :param end_line: int - (optional) ending line number in s to print (default=len(s)) |
| :param expand_tabs: bool - (optional) expand tabs to spaces, to match the pyparsing default |
| :param eol_mark: str - (optional) string to mark the end of lines, helps visualize trailing spaces (default="|") |
| :param mark_spaces: str - (optional) special character to display in place of spaces |
| :param mark_control: str - (optional) convert non-printing control characters to a placeholding |
| character; valid values: |
| - "unicode" - replaces control chars with Unicode symbols, such as "␍" and "␊" |
| - any single character string - replace control characters with given string |
| - None (default) - string is displayed as-is |
| |
| :return: str - input string with leading line numbers and column number headers |
| """ |
| if expand_tabs: |
| s = s.expandtabs() |
| if mark_control is not None: |
| if mark_control == "unicode": |
| tbl = str.maketrans( |
| {c: u for c, u in zip(range(0, 33), range(0x2400, 0x2433))} |
| | {127: 0x2421} |
| ) |
| eol_mark = "" |
| else: |
| tbl = str.maketrans( |
| {c: mark_control for c in list(range(0, 32)) + [127]} |
| ) |
| s = s.translate(tbl) |
| if mark_spaces is not None and mark_spaces != " ": |
| if mark_spaces == "unicode": |
| tbl = str.maketrans({9: 0x2409, 32: 0x2423}) |
| s = s.translate(tbl) |
| else: |
| s = s.replace(" ", mark_spaces) |
| if start_line is None: |
| start_line = 1 |
| if end_line is None: |
| end_line = len(s) |
| end_line = min(end_line, len(s)) |
| start_line = min(max(1, start_line), end_line) |
| |
| if mark_control != "unicode": |
| s_lines = s.splitlines()[start_line - 1 : end_line] |
| else: |
| s_lines = [line + "␊" for line in s.split("␊")[start_line - 1 : end_line]] |
| if not s_lines: |
| return "" |
| |
| lineno_width = len(str(end_line)) |
| max_line_len = max(len(line) for line in s_lines) |
| lead = " " * (lineno_width + 1) |
| if max_line_len >= 99: |
| header0 = ( |
| lead |
| + "".join( |
| "{}{}".format(" " * 99, (i + 1) % 100) |
| for i in range(max(max_line_len // 100, 1)) |
| ) |
| + "\n" |
| ) |
| else: |
| header0 = "" |
| header1 = ( |
| header0 |
| + lead |
| + "".join( |
| " {}".format((i + 1) % 10) |
| for i in range(-(-max_line_len // 10)) |
| ) |
| + "\n" |
| ) |
| header2 = lead + "1234567890" * (-(-max_line_len // 10)) + "\n" |
| return ( |
| header1 |
| + header2 |
| + "\n".join( |
| "{:{}d}:{}{}".format(i, lineno_width, line, eol_mark) |
| for i, line in enumerate(s_lines, start=start_line) |
| ) |
| + "\n" |
| ) |