Re: [PATCH] kunit: tool: internal refactor of parser input handling

From: David Gow
Date: Wed May 26 2021 - 00:43:37 EST


On Wed, May 26, 2021 at 7:43 AM Daniel Latypov <dlatypov@xxxxxxxxxx> wrote:
>
> Note: this does not change the parser behavior at all (except for making
> one error message more useful). This is just an internal refactor.
>
> The TAP output parser currently operates over a List[str].
> This works, but we only ever need to be able to "peek" at the current
> line and the ability to "pop" it off.
>
> Also, using a List means we need to wait for all the output before we
> can start parsing. While this is not an issue for most tests which are
> really lightweight, we do have some longer (~5 minutes) tests.
>
> This patch introduces an Input wrapper class that
> * Exposes a peek()/pop() interface instead of manipulating an array
> * this allows us to more easily add debugging code [1]
> * Can consume an input from a generator
> * we can now parse results as tests are running (the parser code
> currently doesn't print until the end, so no impact yet).
> * Tracks the current line number to print better error messages
> * Would allow us to add additional features more easily, e.g. storing
> N previous lines so we can print out invalid lines in context, etc.

I do like this design of having a test input stream which tracks line
numbers, etc. The peek()/pop() interface does match TAP reasonably
nicely, too: particularly since it's line based. Thumbs up from me!

> [1] The parsing logic is currently quite fragile.
> E.g. it'll often say the kernel "CRASHED" if there's something slightly
> wrong with the output format. When debugging a test that had some memory
> corruption issues, it resulted in very misleading errors from the parser.

Alas, this is very true. The plan is to do a significant rework of the
parser to make it more resilient to input which doesn't exactly match
what we expect (it doesn't even handle all valid TAP streams
correctly).

> Now we could easily add this to trace all the lines consumed and why
> def pop(self) -> str:
> n = self._next
> + print(f'popping {n[0]}: {n[1].ljust(40, " ")}| caller={inspect.stack()[1].function}')

I tried this, and it's very nice. I did need to add an "import
inspect" line for the inspect.stack() function. Not sure if that's
worth mentioning here.
>
> Example output:
> popping 77: TAP version 14 | caller=parse_tap_header
> popping 78: 1..1 | caller=parse_test_plan
> popping 79: # Subtest: kunit_executor_test | caller=parse_subtest_header
> popping 80: 1..2 | caller=parse_subtest_plan
> popping 81: ok 1 - parse_filter_test | caller=parse_ok_not_ok_test_case
> popping 82: ok 2 - filter_subsuite_test | caller=parse_ok_not_ok_test_case
> popping 83: ok 1 - kunit_executor_test | caller=parse_ok_not_ok_test_suite
>
> If we introduce an invalid line, we can see the parser go down the wrong path:
> popping 77: TAP version 14 | caller=parse_tap_header
> popping 78: 1..1 | caller=parse_test_plan
> popping 79: # Subtest: kunit_executor_test | caller=parse_subtest_header
> popping 80: 1..2 | caller=parse_subtest_plan
> popping 81: 1..2 # this is invalid! | caller=parse_ok_not_ok_test_case
> popping 82: ok 1 - parse_filter_test | caller=parse_ok_not_ok_test_case
> popping 83: ok 2 - filter_subsuite_test | caller=parse_ok_not_ok_test_case
> popping 84: ok 1 - kunit_executor_test | caller=parse_ok_not_ok_test_case
> [ERROR] ran out of lines before end token
>
> Signed-off-by: Daniel Latypov <dlatypov@xxxxxxxxxx>
> ---

This looks good. The only issues I have are with the names "Input" and
"get_input", which are a little bit confusing: comments below.

Apart from those, though, this is:
Reviewed-by: David Gow <davidgow@xxxxxxxxxx>

Cheers,
-- David

> tools/testing/kunit/kunit_parser.py | 136 ++++++++++++++++---------
> tools/testing/kunit/kunit_tool_test.py | 18 ++--
> 2 files changed, 99 insertions(+), 55 deletions(-)
>
> diff --git a/tools/testing/kunit/kunit_parser.py b/tools/testing/kunit/kunit_parser.py
> index e8bcc139702e..65adb386364a 100644
> --- a/tools/testing/kunit/kunit_parser.py
> +++ b/tools/testing/kunit/kunit_parser.py
> @@ -47,22 +47,63 @@ class TestStatus(Enum):
> NO_TESTS = auto()
> FAILURE_TO_PARSE_TESTS = auto()
>
> +class Input:

This class name is a little bit confusing, particularly with the
description below of it being an interface around
isolate_kunit_output(). While, on the whole, I agree that this is
"input" from the parser's point of view, the fact that what we're
parsing is kernel output makes the nomenclature here a bit messy.

Maybe name this "ParserInput" or even "TAPTestStream" (which appears
to be the name used for TAP formatted results on the testanything.org
page)?

> + """Provides a more convenient interface over isolate_kunit_output()."""
> + _lines: Iterator[Tuple[int, str]]
> + _next: Tuple[int, str]
> + _done: bool
> +
> + def __init__(self, lines: Iterator[Tuple[int, str]]):
> + self._lines = lines
> + self._done = False
> + self._next = (0, '')
> + self._get_next()
> +
> + def _get_next(self) -> None:
> + try:
> + self._next = next(self._lines)
> + except StopIteration:
> + self._done = True
> +
> + def peek(self) -> str:
> + return self._next[1]
> +
> + def pop(self) -> str:
> + n = self._next
> + self._get_next()
> + return n[1]
> +
> + def __bool__(self) -> bool:
> + return not self._done
> +
> + # Only used by kunit_tool_test.py.
> + def __iter__(self) -> Iterator[str]:
> + while bool(self):
> + yield self.pop()
> +
> + def line_number(self) -> int:
> + return self._next[0]
> +
> kunit_start_re = re.compile(r'TAP version [0-9]+$')
> kunit_end_re = re.compile('(List of all partitions:|'
> 'Kernel panic - not syncing: VFS:)')
>
> -def isolate_kunit_output(kernel_output) -> Iterator[str]:
> - started = False
> - for line in kernel_output:
> - line = line.rstrip() # line always has a trailing \n
> - if kunit_start_re.search(line):
> - prefix_len = len(line.split('TAP version')[0])
> - started = True
> - yield line[prefix_len:] if prefix_len > 0 else line
> - elif kunit_end_re.search(line):
> - break
> - elif started:
> - yield line[prefix_len:] if prefix_len > 0 else line
> +def get_input(kernel_output: Iterable[str]) -> Input:

Again, I think we need a clearer name for "get_input" here.
"extract_tap_stream"/"get_parser_input_from_kernel_log" or something?
Ideally it'd specify that we're doing the isolation/extraction of the
TAP data from the kernel log, to distinguish it from parsing already
isolated TAP, which is something we may wish to do.

> + def isolate_kunit_output(kernel_output: Iterable[str]) -> Iterator[Tuple[int, str]]:
> + line_num = 0
> + started = False
> + for line in kernel_output:
> + line_num += 1
> + line = line.rstrip() # line always has a trailing \n
> + if kunit_start_re.search(line):
> + prefix_len = len(line.split('TAP version')[0])
> + started = True
> + yield line_num, line[prefix_len:]
> + elif kunit_end_re.search(line):
> + break
> + elif started:
> + yield line_num, line[prefix_len:]
> + return Input(lines=isolate_kunit_output(kernel_output))
>
> def raw_output(kernel_output) -> None:
> for line in kernel_output:
> @@ -97,14 +138,14 @@ def print_log(log) -> None:
>
> TAP_ENTRIES = re.compile(r'^(TAP|[\s]*ok|[\s]*not ok|[\s]*[0-9]+\.\.[0-9]+|[\s]*#).*$')
>
> -def consume_non_diagnostic(lines: List[str]) -> None:
> - while lines and not TAP_ENTRIES.match(lines[0]):
> - lines.pop(0)
> +def consume_non_diagnostic(lines: Input) -> None:
> + while lines and not TAP_ENTRIES.match(lines.peek()):
> + lines.pop()
>
> -def save_non_diagnostic(lines: List[str], test_case: TestCase) -> None:
> - while lines and not TAP_ENTRIES.match(lines[0]):
> - test_case.log.append(lines[0])
> - lines.pop(0)
> +def save_non_diagnostic(lines: Input, test_case: TestCase) -> None:
> + while lines and not TAP_ENTRIES.match(lines.peek()):
> + test_case.log.append(lines.peek())
> + lines.pop()
>
> OkNotOkResult = namedtuple('OkNotOkResult', ['is_ok','description', 'text'])
>
> @@ -112,18 +153,18 @@ OK_NOT_OK_SUBTEST = re.compile(r'^[\s]+(ok|not ok) [0-9]+ - (.*)$')
>
> OK_NOT_OK_MODULE = re.compile(r'^(ok|not ok) ([0-9]+) - (.*)$')
>
> -def parse_ok_not_ok_test_case(lines: List[str], test_case: TestCase) -> bool:
> +def parse_ok_not_ok_test_case(lines: Input, test_case: TestCase) -> bool:
> save_non_diagnostic(lines, test_case)
> if not lines:
> test_case.status = TestStatus.TEST_CRASHED
> return True
> - line = lines[0]
> + line = lines.peek()
> match = OK_NOT_OK_SUBTEST.match(line)
> while not match and lines:
> - line = lines.pop(0)
> + line = lines.pop()
> match = OK_NOT_OK_SUBTEST.match(line)
> if match:
> - test_case.log.append(lines.pop(0))
> + test_case.log.append(lines.pop())
> test_case.name = match.group(2)
> if test_case.status == TestStatus.TEST_CRASHED:
> return True
> @@ -138,14 +179,14 @@ def parse_ok_not_ok_test_case(lines: List[str], test_case: TestCase) -> bool:
> SUBTEST_DIAGNOSTIC = re.compile(r'^[\s]+# (.*)$')
> DIAGNOSTIC_CRASH_MESSAGE = re.compile(r'^[\s]+# .*?: kunit test case crashed!$')
>
> -def parse_diagnostic(lines: List[str], test_case: TestCase) -> bool:
> +def parse_diagnostic(lines: Input, test_case: TestCase) -> bool:
> save_non_diagnostic(lines, test_case)
> if not lines:
> return False
> - line = lines[0]
> + line = lines.peek()
> match = SUBTEST_DIAGNOSTIC.match(line)
> if match:
> - test_case.log.append(lines.pop(0))
> + test_case.log.append(lines.pop())
> crash_match = DIAGNOSTIC_CRASH_MESSAGE.match(line)
> if crash_match:
> test_case.status = TestStatus.TEST_CRASHED
> @@ -153,7 +194,7 @@ def parse_diagnostic(lines: List[str], test_case: TestCase) -> bool:
> else:
> return False
>
> -def parse_test_case(lines: List[str]) -> Optional[TestCase]:
> +def parse_test_case(lines: Input) -> Optional[TestCase]:
> test_case = TestCase()
> save_non_diagnostic(lines, test_case)
> while parse_diagnostic(lines, test_case):
> @@ -165,24 +206,24 @@ def parse_test_case(lines: List[str]) -> Optional[TestCase]:
>
> SUBTEST_HEADER = re.compile(r'^[\s]+# Subtest: (.*)$')
>
> -def parse_subtest_header(lines: List[str]) -> Optional[str]:
> +def parse_subtest_header(lines: Input) -> Optional[str]:
> consume_non_diagnostic(lines)
> if not lines:
> return None
> - match = SUBTEST_HEADER.match(lines[0])
> + match = SUBTEST_HEADER.match(lines.peek())
> if match:
> - lines.pop(0)
> + lines.pop()
> return match.group(1)
> else:
> return None
>
> SUBTEST_PLAN = re.compile(r'[\s]+[0-9]+\.\.([0-9]+)')
>
> -def parse_subtest_plan(lines: List[str]) -> Optional[int]:
> +def parse_subtest_plan(lines: Input) -> Optional[int]:
> consume_non_diagnostic(lines)
> - match = SUBTEST_PLAN.match(lines[0])
> + match = SUBTEST_PLAN.match(lines.peek())
> if match:
> - lines.pop(0)
> + lines.pop()
> return int(match.group(1))
> else:
> return None
> @@ -199,17 +240,17 @@ def max_status(left: TestStatus, right: TestStatus) -> TestStatus:
> else:
> return TestStatus.SUCCESS
>
> -def parse_ok_not_ok_test_suite(lines: List[str],
> +def parse_ok_not_ok_test_suite(lines: Input,
> test_suite: TestSuite,
> expected_suite_index: int) -> bool:
> consume_non_diagnostic(lines)
> if not lines:
> test_suite.status = TestStatus.TEST_CRASHED
> return False
> - line = lines[0]
> + line = lines.peek()
> match = OK_NOT_OK_MODULE.match(line)
> if match:
> - lines.pop(0)
> + lines.pop()
> if match.group(1) == 'ok':
> test_suite.status = TestStatus.SUCCESS
> else:
> @@ -231,7 +272,7 @@ def bubble_up_test_case_errors(test_suite: TestSuite) -> TestStatus:
> max_test_case_status = bubble_up_errors(x.status for x in test_suite.cases)
> return max_status(max_test_case_status, test_suite.status)
>
> -def parse_test_suite(lines: List[str], expected_suite_index: int) -> Optional[TestSuite]:
> +def parse_test_suite(lines: Input, expected_suite_index: int) -> Optional[TestSuite]:
> if not lines:
> return None
> consume_non_diagnostic(lines)
> @@ -257,26 +298,26 @@ def parse_test_suite(lines: List[str], expected_suite_index: int) -> Optional[Te
> print_with_timestamp(red('[ERROR] ') + 'ran out of lines before end token')
> return test_suite
> else:
> - print('failed to parse end of suite' + lines[0])
> + print(f'failed to parse end of suite "{name}", at line {lines.line_number()}: {lines.peek()}')
> return None
>
> TAP_HEADER = re.compile(r'^TAP version 14$')
>
> -def parse_tap_header(lines: List[str]) -> bool:
> +def parse_tap_header(lines: Input) -> bool:
> consume_non_diagnostic(lines)
> - if TAP_HEADER.match(lines[0]):
> - lines.pop(0)
> + if TAP_HEADER.match(lines.peek()):
> + lines.pop()
> return True
> else:
> return False
>
> TEST_PLAN = re.compile(r'[0-9]+\.\.([0-9]+)')
>
> -def parse_test_plan(lines: List[str]) -> Optional[int]:
> +def parse_test_plan(lines: Input) -> Optional[int]:
> consume_non_diagnostic(lines)
> - match = TEST_PLAN.match(lines[0])
> + match = TEST_PLAN.match(lines.peek())
> if match:
> - lines.pop(0)
> + lines.pop()
> return int(match.group(1))
> else:
> return None
> @@ -284,7 +325,7 @@ def parse_test_plan(lines: List[str]) -> Optional[int]:
> def bubble_up_suite_errors(test_suites: Iterable[TestSuite]) -> TestStatus:
> return bubble_up_errors(x.status for x in test_suites)
>
> -def parse_test_result(lines: List[str]) -> TestResult:
> +def parse_test_result(lines: Input) -> TestResult:
> consume_non_diagnostic(lines)
> if not lines or not parse_tap_header(lines):
> return TestResult(TestStatus.NO_TESTS, [], lines)
> @@ -338,11 +379,12 @@ def print_and_count_results(test_result: TestResult) -> Tuple[int, int, int]:
> print_with_timestamp('')
> return total_tests, failed_tests, crashed_tests
>
> -def parse_run_tests(kernel_output) -> TestResult:
> +def parse_run_tests(kernel_output: Iterable[str]) -> TestResult:
> total_tests = 0
> failed_tests = 0
> crashed_tests = 0
> - test_result = parse_test_result(list(isolate_kunit_output(kernel_output)))
> + lines = get_input(kernel_output)
> + test_result = parse_test_result(lines)
> if test_result.status == TestStatus.NO_TESTS:
> print(red('[ERROR] ') + yellow('no tests run!'))
> elif test_result.status == TestStatus.FAILURE_TO_PARSE_TESTS:
> diff --git a/tools/testing/kunit/kunit_tool_test.py b/tools/testing/kunit/kunit_tool_test.py
> index 2e809dd956a7..e82678a25bef 100755
> --- a/tools/testing/kunit/kunit_tool_test.py
> +++ b/tools/testing/kunit/kunit_tool_test.py
> @@ -11,6 +11,7 @@ from unittest import mock
>
> import tempfile, shutil # Handling test_tmpdir
>
> +import itertools
> import json
> import signal
> import os
> @@ -92,17 +93,18 @@ class KconfigTest(unittest.TestCase):
>
> class KUnitParserTest(unittest.TestCase):
>
> - def assertContains(self, needle, haystack):
> - for line in haystack:
> + def assertContains(self, needle: str, haystack: kunit_parser.Input):
> + # Clone the iterator so we can print the contents on failure.
> + copy, backup = itertools.tee(haystack)
> + for line in copy:
> if needle in line:
> return
> - raise AssertionError('"' +
> - str(needle) + '" not found in "' + str(haystack) + '"!')
> + raise AssertionError(f'"{needle}" not found in {list(backup)}!')
>
> def test_output_isolated_correctly(self):
> log_path = test_data_path('test_output_isolated_correctly.log')
> with open(log_path) as file:
> - result = kunit_parser.isolate_kunit_output(file.readlines())
> + result = kunit_parser.get_input(file.readlines())
> self.assertContains('TAP version 14', result)
> self.assertContains(' # Subtest: example', result)
> self.assertContains(' 1..2', result)
> @@ -113,7 +115,7 @@ class KUnitParserTest(unittest.TestCase):
> def test_output_with_prefix_isolated_correctly(self):
> log_path = test_data_path('test_pound_sign.log')
> with open(log_path) as file:
> - result = kunit_parser.isolate_kunit_output(file.readlines())
> + result = kunit_parser.get_input(file.readlines())
> self.assertContains('TAP version 14', result)
> self.assertContains(' # Subtest: kunit-resource-test', result)
> self.assertContains(' 1..5', result)
> @@ -159,7 +161,7 @@ class KUnitParserTest(unittest.TestCase):
> empty_log = test_data_path('test_is_test_passed-no_tests_run.log')
> with open(empty_log) as file:
> result = kunit_parser.parse_run_tests(
> - kunit_parser.isolate_kunit_output(file.readlines()))
> + kunit_parser.get_input(file.readlines()))
> self.assertEqual(0, len(result.suites))
> self.assertEqual(
> kunit_parser.TestStatus.NO_TESTS,
> @@ -170,7 +172,7 @@ class KUnitParserTest(unittest.TestCase):
> print_mock = mock.patch('builtins.print').start()
> with open(crash_log) as file:
> result = kunit_parser.parse_run_tests(
> - kunit_parser.isolate_kunit_output(file.readlines()))
> + kunit_parser.get_input(file.readlines()))
> print_mock.assert_any_call(StrContains('no tests run!'))
> print_mock.stop()
> file.close()
>
> base-commit: c3d0e3fd41b7f0f5d5d5b6022ab7e813f04ea727
> --
> 2.31.1.818.g46aad6cb9e-goog
>