| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398 |
- # testing.py
- from contextlib import contextmanager
- import re
- import typing
- import unittest
- from .core import (
- ParserElement,
- ParseException,
- Keyword,
- __diag__,
- __compat__,
- )
- from . import core_builtin_exprs
- 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:
- .. testcode::
- ppt = pyparsing.pyparsing_test
- class MyTestClass(ppt.TestParseResultsAsserts):
- def test_literal(self):
- with ppt.reset_pyparsing_context():
- # test that literals used to construct
- # a grammar are automatically suppressed
- ParserElement.inline_literals_using(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.inline_literals_using(
- 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"
- ]
- # clear debug flags on all builtins
- for expr in core_builtin_exprs:
- expr.set_debug(False)
- __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(unittest.TestCase):
- """
- 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 :meth:`ParseResults.as_list` 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 :meth:`ParseResults.as_dict` is equal to the ``expected_dict``.
- """
- result = expr.parse_string(test_string, parse_all=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
- :meth:`~ParserElement.run_tests`.
- 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 ``run_tests()``
- and evaluated using :meth:`assertParseResultsEquals`.
- Finally, asserts that the overall
- `:meth:~ParserElement.run_tests` success value is ``True``.
- :param run_tests_report: the return value from :meth:`ParserElement.run_tests`
- :type run_tests_report: tuple[bool, list[tuple[str, ParseResults | Exception]]]
- :param expected_parse_results: (optional)
- :type expected_parse_results: list[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: string to be printed with line and column numbers
- :param start_line: starting line number in s to print (default=1)
- :param end_line: ending line number in s to print (default=len(s))
- :param expand_tabs: expand tabs to spaces, to match the pyparsing default
- :param eol_mark: string to mark the end of lines, helps visualize trailing spaces
- :param mark_spaces: special character to display in place of spaces
- :param mark_control: 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: string to indent with line and column numbers; if an int
- is passed, converted to ``" " * indent``
- :param base_1: whether to label string using base 1; if False, string will be
- labeled based at 0
- :returns: input string with leading line numbers and column number headers
- .. versionchanged:: 3.2.0
- New ``indent`` and ``base_1`` arguments.
- """
- 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.splitlines())
- end_line = min(end_line, len(s.splitlines()))
- start_line = min(max(0, start_line), end_line)
- if mark_control != "unicode":
- s_lines = s.splitlines()[max(start_line - base_1, 0) : end_line]
- else:
- s_lines = [
- line + "␊"
- for line in s.split("␊")[max(start_line - base_1, 0) : 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 = f"{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(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 (
- header0
- + 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"
- )
|