diff options
Diffstat (limited to 'tools/unittests/test_kdoc_parser.py')
| -rwxr-xr-x | tools/unittests/test_kdoc_parser.py | 560 |
1 files changed, 560 insertions, 0 deletions
diff --git a/tools/unittests/test_kdoc_parser.py b/tools/unittests/test_kdoc_parser.py new file mode 100755 index 000000000000..c4a76ed13dbc --- /dev/null +++ b/tools/unittests/test_kdoc_parser.py @@ -0,0 +1,560 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0 +# Copyright(c) 2026: Mauro Carvalho Chehab <mchehab@kernel.org>. +# +# pylint: disable=C0200,C0413,W0102,R0914 + +""" +Unit tests for kernel-doc parser. +""" + +import logging +import os +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_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 TestUnits + + +# +# Test file +# +TEST_FILE = os.path.join(SRC_DIR, "kdoc-test.yaml") + +env = { + "yaml_file": TEST_FILE +} + +# +# Ancillary logic to clean whitespaces +# +#: Regex to help cleaning whitespaces +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): + """ + Cleanup whitespaces to avoid false positives. + + By default, strip only bein/end whitespaces, but, when relax_whitespace + is true, also replace multiple whitespaces in the middle. + """ + + if isinstance(val, str): + val = val.strip() + if relax_whitespace: + 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): + val = {k: clean_whitespc(v, relax_whitespace) for k, v in val.items()} + return val + +# +# Helper classes to help mocking with logger and config +# +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 + + def run_test(self, source, __expected_list, exports={}, fname="test.c", + relax_whitespace=False): + """ + Stores expected values and patch the test to use source as + a "file" input. + """ + debug_level = int(os.getenv("VERBOSE", "0")) + source = dedent(source) + + # 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(): + new_e[key] = value + + expected_list.append(new_e) + + 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() + + self.assertEqual(export_table, exports) + self.assertEqual(len(entries), len(expected_list)) + + for i in range(0, len(entries)): + + entry = entries[i] + expected = expected_list[i] + self.assertNotEqual(expected, None) + self.assertNotEqual(expected, {}) + self.assertIsInstance(entry, KdocItem) + + d = vars(entry) + + other_stuff = d.get("other_stuff", {}) + if "source" in other_stuff: + del other_stuff["source"] + + for key, value in expected.items(): + if key == "other_stuff": + if "source" in value: + del value["source"] + + result = clean_whitespc(d[key], relax_whitespace) + value = clean_whitespc(value, relax_whitespace) + + if debug_level > 1: + sys.stderr.write(f"{key}: assert('{result}' == '{value}')\n") + + 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) + + +# +# Selftest class +# +class TestSelfValidate(GenerateKdocItem): + """ + Tests to check if logic inside GenerateKdocItem.run_test() is working. + """ + + SOURCE = """ + /** + * function3: Exported function + * @arg1: @arg1 does nothing + * + * Does nothing + * + * return: + * always return 0. + */ + int function3(char *arg1) { return 0; }; + EXPORT_SYMBOL(function3); + """ + + EXPECTED = [{ + 'name': 'function3', + 'type': 'function', + 'declaration_start_line': 2, + + 'sections_start_lines': { + 'Description': 4, + 'Return': 7, + }, + 'sections': { + '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 + }, + }] + + EXPORTS = {"function3"} + + def test_parse_pass(self): + """ + Test if export_symbol is properly handled. + """ + self.run_test(self.SOURCE, self.EXPECTED, self.EXPORTS) + + @unittest.expectedFailure + def test_no_exports(self): + """ + Test if export_symbol is properly handled. + """ + self.run_test(self.SOURCE, [], {}) + + @unittest.expectedFailure + def test_with_empty_expected(self): + """ + Test if export_symbol is properly handled. + """ + self.run_test(self.SOURCE, [], self.EXPORTS) + + @unittest.expectedFailure + def test_with_unfilled_expected(self): + """ + Test if export_symbol is properly handled. + """ + self.run_test(self.SOURCE, [{}], self.EXPORTS) + + @unittest.expectedFailure + def test_with_default_expected(self): + """ + Test if export_symbol is properly handled. + """ + 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. + """ + + test_file = os.environ.get("yaml_file", TEST_FILE) + + 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 +# +if __name__ == "__main__": + runner = TestUnits() + parser = runner.parse_args() + parser.add_argument("-y", "--yaml-file", "--yaml", + help='Name of the yaml file to load') + + args = parser.parse_args() + + if args.yaml_file: + env["yaml_file"] = os.path.expanduser(args.yaml_file) + + # Run tests with customized arguments + runner.run(__file__, parser=parser, args=args, env=env) |
