# testing.py from contextlib import contextmanager import re import typing 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 None: self.assertTrue( run_test_success, msg=msg if msg is not None else "failed runTests" ) return 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(f"no validation for {test_string!r}") # 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, expected_msg=None, msg=None ): if expected_msg is not None: if isinstance(expected_msg, str): expected_msg = re.escape(expected_msg) with self.assertRaisesRegex(exc_type, expected_msg, msg=msg) as ctx: yield ctx else: with self.assertRaises(exc_type, msg=msg) as ctx: yield ctx @staticmethod def with_line_numbers( s: str, start_line: typing.Optional[int] = None, end_line: typing.Optional[int] = None, expand_tabs: bool = True, eol_mark: str = "|", mark_spaces: typing.Optional[str] = None, mark_control: typing.Optional[str] = None, *, indent: typing.Union[str, int] = "", base_1: bool = True, ) -> str: """ Helpful method for debugging a parser - prints a string with line and column numbers. (Line and column numbers are 1-based by default - if debugging a parse action, pass base_1=False, to correspond to the loc value passed to the parse action.) :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 :param indent: str | int - (optional) string to indent with line and column numbers; if an int is passed, converted to " " * indent :param base_1: bool - (optional) whether to label string using base 1; if False, string will be labeled based at 0 (default=True) :return: str - input string with leading line numbers and column number headers """ if expand_tabs: s = s.expandtabs() if isinstance(indent, int): indent = " " * indent indent = indent.expandtabs() if mark_control is not None: mark_control = typing.cast(str, mark_control) if mark_control == "unicode": transtable_map = { c: u for c, u in zip(range(0, 33), range(0x2400, 0x2433)) } transtable_map[127] = 0x2421 tbl = str.maketrans(transtable_map) eol_mark = "" else: ord_mark_control = ord(mark_control) tbl = str.maketrans( {c: ord_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 = 0 if end_line is None: end_line = len(s) end_line = min(end_line, len(s)) start_line = min(max(0, start_line), end_line) if mark_control != "unicode": s_lines = s.splitlines()[start_line - base_1 : end_line] else: s_lines = [ line + "␊" for line in s.split("␊")[start_line - base_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 = indent + " " * (lineno_width + 1) if max_line_len >= 99: header0 = ( lead + ("" if base_1 else " ") + "".join( f"{' ' * 99}{(i + 1) % 100}" for i in range(1 if base_1 else 0, max(max_line_len // 100, 1)) ) + "\n" ) else: header0 = "" header1 = ( ("" if base_1 else " ") + lead + "".join(f" {(i + 1) % 10}" for i in range(-(-max_line_len // 10))) + "\n" ) digits = "1234567890" header2 = ( lead + ("" if base_1 else "0") + digits * (-(-max_line_len // 10)) + "\n" ) return ( header1 + header2 + "\n".join( f"{indent}{i:{lineno_width}d}:{line}{eol_mark}" for i, line in enumerate(s_lines, start=start_line + base_1) ) + "\n" )