python (3.11.7)
       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 ESC[4;38;5;81mpyparsing_test:
      16      """
      17      namespace class for classes useful in writing unit tests
      18      """
      19  
      20      class ESC[4;38;5;81mreset_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 ESC[4;38;5;81mTestParseResultsAsserts:
     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          )