linux/tools/unittests/test_kdoc_parser.py

561 lines
16 KiB
Python
Executable File

#!/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)