[PATCH 12/14] docs: test_kdoc_parser: add support for dynamic test creation

Mauro Carvalho Chehab posted 14 patches 2 weeks, 5 days ago
[PATCH 12/14] docs: test_kdoc_parser: add support for dynamic test creation
Posted by Mauro Carvalho Chehab 2 weeks, 5 days ago
Use the content of kdoc-test.yaml to generate unittests to
verify that kernel-doc internal methods are parsing C code
and generating output the expected way.

Depending on what is written at the parser file at
kdoc-test.yaml, up to 5 tests can be generated from a single
test entry inside the YAML file:

1. from source to kdoc_item: test KernelDoc class;
2. from kdoc_item to man: test ManOutput class;
3. from kdoc_item to rst: test RestOutput class;
4. from source to man without checking expected KdocItem;
5. from source to rst without checking expected KdocItem.

Signed-off-by: Mauro Carvalho Chehab <mchehab+huawei@kernel.org>
---
 tools/unittests/test_kdoc_parser.py | 368 ++++++++++++++++++++++++++--
 1 file changed, 350 insertions(+), 18 deletions(-)

diff --git a/tools/unittests/test_kdoc_parser.py b/tools/unittests/test_kdoc_parser.py
index 26f74666a000..723dd8c7f4f3 100755
--- a/tools/unittests/test_kdoc_parser.py
+++ b/tools/unittests/test_kdoc_parser.py
@@ -8,24 +8,43 @@
 Unit tests for kernel-doc parser.
 """
 
+import logging
 import os
-import unittest
 import re
+import shlex
 import sys
+import unittest
 
 from textwrap import dedent
 from unittest.mock import patch, MagicMock, mock_open
 
+import yaml
+
 SRC_DIR = os.path.dirname(os.path.realpath(__file__))
 sys.path.insert(0, os.path.join(SRC_DIR, "../lib/python"))
 
-from kdoc.kdoc_parser import KernelDoc
+from kdoc.kdoc_files import KdocConfig
 from kdoc.kdoc_item import KdocItem
+from kdoc.kdoc_parser import KernelDoc
+from kdoc.kdoc_output import RestFormat, ManFormat
+
 from kdoc.xforms_lists import CTransforms
+
 from unittest_helper import run_unittest
 
+
+#
+# Test file
+#
+TEST_FILE = os.path.join(SRC_DIR, "kdoc-test.yaml")
+
+#
+# Ancillary logic to clean whitespaces
+#
 #: Regex to help cleaning whitespaces
-RE_WHITESPC = re.compile(r"\s++")
+RE_WHITESPC = re.compile(r"[ \t]++")
+RE_BEGINSPC = re.compile(r"^\s+", re.MULTILINE)
+RE_ENDSPC = re.compile(r"\s+$", re.MULTILINE)
 
 def clean_whitespc(val, relax_whitespace=False):
     """
@@ -38,7 +57,9 @@ def clean_whitespc(val, relax_whitespace=False):
     if isinstance(val, str):
         val = val.strip()
         if relax_whitespace:
-            val = RE_WHITESPC.sub("", val)
+            val = RE_WHITESPC.sub(" ", val)
+            val = RE_BEGINSPC.sub("", val)
+            val = RE_ENDSPC.sub("", val)
     elif isinstance(val, list):
         val = [clean_whitespc(item, relax_whitespace) for item in val]
     elif isinstance(val, dict):
@@ -46,22 +67,59 @@ def clean_whitespc(val, relax_whitespace=False):
     return val
 
 #
-# Helper class to help mocking with
+# Helper classes to help mocking with logger and config
 #
-class KdocParser(unittest.TestCase):
+class MockLogging(logging.Handler):
+    """
+    Simple class to store everything on a list
+    """
+
+    def __init__(self, level=logging.NOTSET):
+        super().__init__(level)
+        self.messages = []
+        self.formatter = logging.Formatter()
+
+    def emit(self, record: logging.LogRecord) -> None:
+        """
+        Append a formatted record to self.messages.
+        """
+        try:
+            # The `format` method uses the handler's formatter.
+            message = self.format(record)
+            self.messages.append(message)
+        except Exception:
+            self.handleError(record)
+
+class MockKdocConfig(KdocConfig):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self.log = logging.getLogger(__file__)
+        self.handler = MockLogging()
+        self.log.addHandler(self.handler)
+
+    def warning(self, msg):
+        """Ancillary routine to output a warning and increment error count."""
+
+        self.log.warning(msg)
+
+#
+# Helper class to generate KdocItem and validate its contents
+#
+# TODO: check self.config.handler.messages content
+#
+class GenerateKdocItem(unittest.TestCase):
     """
     Base class to run KernelDoc parser class
     """
 
     DEFAULT = vars(KdocItem("", "", "", 0))
 
+    config = MockKdocConfig()
+    xforms = CTransforms()
+
     def setUp(self):
         self.maxDiff = None
-        self.config = MagicMock()
-        self.config.log = MagicMock()
-        self.config.log.debug = MagicMock()
-        self.xforms = CTransforms()
-
 
     def run_test(self, source, __expected_list, exports={}, fname="test.c",
                  relax_whitespace=False):
@@ -75,6 +133,9 @@ class KdocParser(unittest.TestCase):
         # Ensure that default values will be there
         expected_list = []
         for e in __expected_list:
+            if not isinstance(e, dict):
+                e = vars(e)
+
             new_e = self.DEFAULT.copy()
             new_e["fname"] = fname
             for key, value in e.items():
@@ -111,13 +172,155 @@ class KdocParser(unittest.TestCase):
 
                     self.assertEqual(result, value, msg=f"at {key}")
 
+#
+# Ancillary function that replicates kdoc_files way to generate output
+#
+def cleanup_timestamp(text):
+    lines = text.split("\n")
+
+    for i, line in enumerate(lines):
+        if not line.startswith('.TH'):
+            continue
+
+        parts = shlex.split(line)
+        if len(parts) > 3:
+            parts[3] = ""
+
+        lines[i] = " ".join(parts)
+
+
+    return "\n".join(lines)
+
+def gen_output(fname, out_style, symbols, expected,
+               config=None, relax_whitespace=False):
+    """
+    Use the output class to return an output content from KdocItem symbols.
+    """
+
+    if not config:
+        config = MockKdocConfig()
+
+    out_style.set_config(config)
+
+    msg = out_style.output_symbols(fname, symbols)
+
+    result = clean_whitespc(msg, relax_whitespace)
+    result = cleanup_timestamp(result)
+
+    expected = clean_whitespc(expected, relax_whitespace)
+    expected = cleanup_timestamp(expected)
+
+    return result, expected
+
+#
+# Classes to be used by dynamic test generation from YAML
+#
+class CToKdocItem(GenerateKdocItem):
+    def setUp(self):
+        self.maxDiff = None
+
+    def run_parser_test(self, source, symbols, exports, fname):
+        if isinstance(symbols, dict):
+            symbols = [symbols]
+
+        if isinstance(exports, str):
+            exports=set([exports])
+        elif isinstance(exports, list):
+            exports=set(exports)
+
+        self.run_test(source, symbols, exports=exports,
+                      fname=fname, relax_whitespace=True)
+
+class KdocItemToMan(unittest.TestCase):
+    out_style = ManFormat()
+
+    def setUp(self):
+        self.maxDiff = None
+
+    def run_out_test(self, fname, symbols, expected):
+        """
+        Generate output using out_style,
+        """
+        result, expected = gen_output(fname, self.out_style,
+                                      symbols, expected)
+
+        self.assertEqual(result, expected)
+
+class KdocItemToRest(unittest.TestCase):
+    out_style = RestFormat()
+
+    def setUp(self):
+        self.maxDiff = None
+
+    def run_out_test(self, fname, symbols, expected):
+        """
+        Generate output using out_style,
+        """
+        result, expected = gen_output(fname, self.out_style, symbols,
+                                      expected, relax_whitespace=True)
+
+        self.assertEqual(result, expected)
+
+
+class CToMan(unittest.TestCase):
+    out_style = ManFormat()
+    config = MockKdocConfig()
+    xforms = CTransforms()
+
+    def setUp(self):
+        self.maxDiff = None
+
+    def run_out_test(self, fname, source, expected):
+        """
+        Generate output using out_style,
+        """
+        patcher = patch('builtins.open',
+                        new_callable=mock_open, read_data=source)
+
+        kernel_doc = KernelDoc(self.config, fname, self.xforms)
+
+        with patcher:
+            export_table, entries = kernel_doc.parse_kdoc()
+
+        result, expected = gen_output(fname, self.out_style,
+                                      entries, expected, config=self.config)
+
+        self.assertEqual(result, expected)
+
+
+class CToRest(unittest.TestCase):
+    out_style = RestFormat()
+    config = MockKdocConfig()
+    xforms = CTransforms()
+
+    def setUp(self):
+        self.maxDiff = None
+
+    def run_out_test(self, fname, source, expected):
+        """
+        Generate output using out_style,
+        """
+        patcher = patch('builtins.open',
+                        new_callable=mock_open, read_data=source)
+
+        kernel_doc = KernelDoc(self.config, fname, self.xforms)
+
+        with patcher:
+            export_table, entries = kernel_doc.parse_kdoc()
+
+        result, expected = gen_output(fname, self.out_style, entries,
+                                      expected, relax_whitespace=True,
+                                      config=self.config)
+
+        self.assertEqual(result, expected)
+
 
 #
-# Selttest class
+# Selftest class
 #
-class TestSelfValidate(KdocParser):
+class TestSelfValidate(GenerateKdocItem):
     """
-    Tests to check if logic inside KdocParser.run_test() is working.
+    Tests to check if logic inside GenerateKdocItem.run_test() is working.
     """
 
     SOURCE = """
@@ -147,16 +350,23 @@ class TestSelfValidate(KdocParser):
             'Description': 'Does nothing\n\n',
             'Return': '\nalways return 0.\n'
         },
+
+        'sections_start_lines': {
+            'Description': 4,
+            'Return': 7,
+        },
+
+        'parameterdescs': {'arg1': '@arg1 does nothing\n'},
+        'parameterlist': ['arg1'],
+        'parameterdesc_start_lines': {'arg1': 3},
+        'parametertypes': {'arg1': 'char *arg1'},
+
         'other_stuff': {
             'func_macro': False,
             'functiontype': 'int',
             'purpose': 'Exported function',
             'typedef': False
         },
-        'parameterdescs': {'arg1': '@arg1 does nothing\n'},
-        'parameterlist': ['arg1'],
-        'parameterdesc_start_lines': {'arg1': 3},
-        'parametertypes': {'arg1': 'char *arg1'},
     }]
 
     EXPORTS = {"function3"}
@@ -195,6 +405,128 @@ class TestSelfValidate(KdocParser):
         """
         self.run_test(self.SOURCE, [self.DEFAULT.copy()], self.EXPORTS)
 
+#
+# Class and logic to create dynamic tests from YAML
+#
+
+class KernelDocDynamicTests():
+    """
+    Dynamically create a set of tests from a YAML file.
+    """
+
+    @classmethod
+    def create_parser_test(cls, name, fname, source, symbols, exports):
+        """
+        Return a function that will be attached to the test class.
+        """
+        def test_method(self):
+            """Lambda-like function to run tests with provided vars"""
+            self.run_parser_test(source, symbols, exports, fname)
+
+        test_method.__name__ = f"test_gen_{name}"
+
+        setattr(CToKdocItem, test_method.__name__, test_method)
+
+    @classmethod
+    def create_out_test(cls, name, fname, symbols, out_type, data):
+        """
+        Return a function that will be attached to the test class.
+        """
+        def test_method(self):
+            """Lambda-like function to run tests with provided vars"""
+            self.run_out_test(fname, symbols, data)
+
+        test_method.__name__ = f"test_{out_type}_{name}"
+
+        if out_type == "man":
+            setattr(KdocItemToMan, test_method.__name__, test_method)
+        else:
+            setattr(KdocItemToRest, test_method.__name__, test_method)
+
+    @classmethod
+    def create_src2out_test(cls, name, fname, source, out_type, data):
+        """
+        Return a function that will be attached to the test class.
+        """
+        def test_method(self):
+            """Lambda-like function to run tests with provided vars"""
+            self.run_out_test(fname, source,  data)
+
+        test_method.__name__ = f"test_{out_type}_{name}"
+
+        if out_type == "man":
+            setattr(CToMan, test_method.__name__, test_method)
+        else:
+            setattr(CToRest, test_method.__name__, test_method)
+
+    @classmethod
+    def create_tests(cls):
+        """
+        Iterate over all scenarios and add a method to the class for each.
+
+        The logic in this function assumes a valid test that are compliant
+        with kdoc-test-schema.yaml. There is an unit test to check that.
+        As such, it picks mandatory values directly, and uses get() for the
+        optional ones.
+        """
+
+        with open(TEST_FILE, encoding="utf-8") as fp:
+            testset = yaml.safe_load(fp)
+
+        tests = testset["tests"]
+
+        for idx, test in enumerate(tests):
+            name = test["name"]
+            fname = test["fname"]
+            source = test["source"]
+            expected_list = test["expected"]
+
+            exports = test.get("exports", [])
+
+            #
+            # The logic below allows setting up to 5 types of test:
+            # 1. from source to kdoc_item: test KernelDoc class;
+            # 2. from kdoc_item to man: test ManOutput class;
+            # 3. from kdoc_item to rst: test RestOutput class;
+            # 4. from source to man without checking expected KdocItem;
+            # 5. from source to rst without checking expected KdocItem.
+            #
+            for expected in expected_list:
+                kdoc_item = expected.get("kdoc_item")
+                man = expected.get("man", [])
+                rst = expected.get("rst", [])
+
+                if kdoc_item:
+                    if isinstance(kdoc_item, dict):
+                        kdoc_item = [kdoc_item]
+
+                    symbols = []
+
+                    for arg in kdoc_item:
+                        arg["fname"] = fname
+                        arg["start_line"] = 1
+
+                        symbols.append(KdocItem.from_dict(arg))
+
+                    if source:
+                        cls.create_parser_test(name, fname, source,
+                                               symbols, exports)
+
+                    if man:
+                        cls.create_out_test(name, fname, symbols, "man", man)
+
+                    if rst:
+                        cls.create_out_test(name, fname, symbols, "rst", rst)
+
+                elif source:
+                    if man:
+                        cls.create_src2out_test(name, fname, source, "man", man)
+
+                    if rst:
+                        cls.create_src2out_test(name, fname, source, "rst", rst)
+
+KernelDocDynamicTests.create_tests()
+
 #
 # Run all tests
 #
-- 
2.53.0