diff options
Diffstat (limited to 'tools/docs')
| -rwxr-xr-x | tools/docs/checktransupdate.py | 22 | ||||
| -rwxr-xr-x | tools/docs/kdoc_diff | 508 | ||||
| -rwxr-xr-x | tools/docs/kernel-doc | 49 | ||||
| -rwxr-xr-x | tools/docs/sphinx-build-wrapper | 17 |
4 files changed, 577 insertions, 19 deletions
diff --git a/tools/docs/checktransupdate.py b/tools/docs/checktransupdate.py index e894652369a5..d7b98753e35f 100755 --- a/tools/docs/checktransupdate.py +++ b/tools/docs/checktransupdate.py @@ -13,6 +13,8 @@ The usage is as follows: This will print all the files that need to be updated or translated in the zh_CN locale. - tools/docs/checktransupdate.py Documentation/translations/zh_CN/dev-tools/testing-overview.rst This will only print the status of the specified file. +- tools/docs/checktransupdate.py Documentation/translations/zh_CN/dev-tools +This will print the status of all files under the directory. The output is something like: Documentation/dev-tools/kfence.rst @@ -76,11 +78,11 @@ def get_origin_from_trans_smartly(origin_path, t_from_head): (2) Update the translation through commit HASH (TITLE) """ # catch flag for 12-bit commit hash - HASH = r'([0-9a-f]{12})' + hash_re = r'([0-9a-f]{12})' # pattern 1: contains "update to commit HASH" - pat_update_to = re.compile(rf'update to commit {HASH}') + pat_update_to = re.compile(rf'update to commit {hash_re}') # pattern 2: contains "Update the translation through commit HASH" - pat_update_translation = re.compile(rf'Update the translation through commit {HASH}') + pat_update_translation = re.compile(rf'Update the translation through commit {hash_re}') origin_commit_hash = None for line in t_from_head["message"]: @@ -131,7 +133,7 @@ def check_per_file(file_path): opath = get_origin_path(file_path) if not os.path.isfile(opath): - logging.error("Cannot find the origin path for {file_path}") + logging.error("Cannot find the origin path for %s", file_path) return o_from_head = get_latest_commit_from(opath, "HEAD") @@ -262,7 +264,7 @@ def main(): help='Set the logging file (default: checktransupdate.log)') parser.add_argument( - "files", nargs="*", help="Files to check, if not specified, check all files" + "files", nargs="*", help="Files or directories to check, if not specified, check all files" ) args = parser.parse_args() @@ -293,6 +295,16 @@ def main(): if args.print_missing_translations: logging.info(os.path.relpath(os.path.abspath(file), linux_path)) logging.info("No translation in the locale of %s\n", args.locale) + else: + # check if the files are directories or files + new_files = [] + for file in files: + if os.path.isfile(file): + new_files.append(file) + elif os.path.isdir(file): + # for directories, list all files in the directory and its subfolders + new_files.extend(list_files_with_excluding_folders(file, [], "rst")) + files = new_files files = list(map(lambda x: os.path.relpath(os.path.abspath(x), linux_path), files)) diff --git a/tools/docs/kdoc_diff b/tools/docs/kdoc_diff new file mode 100755 index 000000000000..1aa16bdccaa3 --- /dev/null +++ b/tools/docs/kdoc_diff @@ -0,0 +1,508 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-2.0 +# Copyright(c) 2026: Mauro Carvalho Chehab <mchehab@kernel.org>. +# +# pylint: disable=R0903,R0912,R0913,R0914,R0915,R0917 + +""" +docdiff - Check differences between kernel‑doc output between two different +commits. + +Examples +-------- + +Compare the kernel‑doc output between the last two 5.15 releases:: + + $ kdoc_diff v6.18..v6.19 + +Both outputs are cached + +Force a complete documentation scan and clean any previous cache from +6.19 to the current HEAD:: + + $ kdoc_diff 6.19.. --full --clean + +Check differences only on a single driver since origin/main:: + + $ kdoc_diff origin/main drivers/media + +Generate an YAML file and use it to check for regressions:: + + $ kdoc_diff HEAD~ drivers/media --regression + + +""" + +import os +import sys +import argparse +import subprocess +import shutil +import re +import signal + +from glob import iglob + + +SRC_DIR = os.path.dirname(os.path.realpath(__file__)) +WORK_DIR = os.path.abspath(os.path.join(SRC_DIR, "../..")) + +KDOC_BINARY = os.path.join(SRC_DIR, "kernel-doc") +KDOC_PARSER_TEST = os.path.join(WORK_DIR, "tools/unittests/test_kdoc_parser.py") + +CACHE_DIR = ".doc_diff_cache" +YAML_NAME = "out.yaml" + +DIR_NAME = { + "full": os.path.join(CACHE_DIR, "full"), + "partial": os.path.join(CACHE_DIR, "partial"), + "no-cache": os.path.join(CACHE_DIR, "no_cache"), + "tmp": os.path.join(CACHE_DIR, "__tmp__"), +} + +class GitHelper: + """Handles all Git operations""" + + def __init__(self, work_dir=None): + self.work_dir = work_dir + + def is_inside_repository(self): + """Check if we're inside a Git repository""" + try: + output = subprocess.check_output(["git", "rev-parse", + "--is-inside-work-tree"], + cwd=self.work_dir, + stderr=subprocess.STDOUT, + universal_newlines=True) + + return output.strip() == "true" + except subprocess.CalledProcessError: + return False + + def is_valid_commit(self, commit_hash): + """ + Validate that a ref (branch, tag, commit hash, etc.) can be + resolved to a commit. + """ + try: + subprocess.check_output(["git", "rev-parse", commit_hash], + cwd=self.work_dir, + stderr=subprocess.STDOUT) + return True + except subprocess.CalledProcessError: + return False + + def get_short_hash(self, commit_hash): + """Get short commit hash""" + try: + return subprocess.check_output(["git", "rev-parse", "--short", + commit_hash], + cwd=self.work_dir, + stderr=subprocess.STDOUT, + universal_newlines=True).strip() + except subprocess.CalledProcessError: + return "" + + def has_uncommitted_changes(self): + """Check for uncommitted changes""" + try: + subprocess.check_output(["git", "diff-index", + "--quiet", "HEAD", "--"], + cwd=self.work_dir, + stderr=subprocess.STDOUT) + return False + except subprocess.CalledProcessError: + return True + + def get_current_branch(self): + """Get current branch name""" + return subprocess.check_output(["git", "branch", "--show-current"], + cwd=self.work_dir, + universal_newlines=True).strip() + + def checkout_commit(self, commit_hash, quiet=True): + """Checkout a commit safely""" + args = ["git", "checkout", "-f"] + if quiet: + args.append("-q") + args.append(commit_hash) + try: + subprocess.check_output(args, cwd=self.work_dir, + stderr=subprocess.STDOUT) + + # Double-check if branch actually switched + branch = self.get_short_hash("HEAD") + if commit_hash != branch: + raise RuntimeError(f"Branch changed to '{branch}' instead of '{commit_hash}'") + + return True + except subprocess.CalledProcessError as e: + print(f"ERROR: Failed to checkout {commit_hash}: {e}", + file=sys.stderr) + return False + + +class CacheManager: + """Manages persistent cache directories""" + + def __init__(self, work_dir): + self.work_dir = work_dir + + def initialize(self): + """Create cache directories if they don't exist""" + for dir_path in DIR_NAME.values(): + abs_path = os.path.join(self.work_dir, dir_path) + if not os.path.exists(abs_path): + os.makedirs(abs_path, exist_ok=True, mode=0o755) + + def get_commit_cache(self, commit_hash, path): + """Generate cache path for a commit""" + hash_short = GitHelper(self.work_dir).get_short_hash(commit_hash) + if not hash_short: + hash_short = commit_hash + + return os.path.join(path, hash_short) + +class KernelDocRunner: + """Runs kernel-doc documentation generator""" + + def __init__(self, work_dir, kdoc_binary): + self.work_dir = work_dir + self.kdoc_binary = kdoc_binary + self.kdoc_files = None + + def find_kdoc_references(self): + """Find all files marked with kernel-doc:: directives""" + if self.kdoc_files: + print("Using cached Kdoc refs") + return self.kdoc_files + + print("Finding kernel-doc entries in Documentation...") + + files = os.path.join(self.work_dir, 'Documentation/**/*.rst') + pattern = re.compile(r"^\.\.\s+kernel-doc::\s*(\S+)") + kdoc_files = set() + + for file_path in iglob(files, recursive=True): + try: + with open(file_path, 'r', encoding='utf-8') as fp: + for line in fp: + match = pattern.match(line.strip()) + if match: + kdoc_files.add(match.group(1)) + + except OSError: + continue + + self.kdoc_files = list(kdoc_files) + + return self.kdoc_files + + def gen_yaml(self, yaml_file, kdoc_files): + """Runs kernel-doc to generate a yaml file with man and rst.""" + cmd = [self.kdoc_binary, "--man", "--rst", "--yaml", yaml_file] + cmd += kdoc_files + + print(f"YAML regression test file will be stored at: {yaml_file}") + + try: + subprocess.check_call(cmd, cwd=self.work_dir, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + except subprocess.CalledProcessError: + return False + + return True + + def run_unittest(self, yaml_file): + """Run unit tests with the generated yaml file""" + cmd = [KDOC_PARSER_TEST, "-q", "--yaml", yaml_file] + result = subprocess.run(cmd, cwd=self.work_dir) + + if result.returncode: + print("To check for problems, try to run it again with -v\n") + print("Use -k <regex> to filter results\n\n\t$", end="") + print(" ".join(cmd) + "\n") + + return True + + def normal_run(self, tmp_dir, output_dir, kdoc_files): + """Generate man, rst and errors, storing them at tmp_dir.""" + os.makedirs(tmp_dir, exist_ok=True) + + try: + with open(os.path.join(tmp_dir, "man.log"), "w", encoding="utf-8") as out: + subprocess.check_call([self.kdoc_binary, "--man"] + kdoc_files, + cwd=self.work_dir, + stdout=out, stderr=subprocess.DEVNULL) + + with open(os.path.join(tmp_dir, "rst.log"), "w", encoding="utf-8") as out: + with open(os.path.join(tmp_dir, "err.log"), "w", encoding="utf-8") as err: + subprocess.check_call([self.kdoc_binary, "--rst"] + kdoc_files, + cwd=self.work_dir, + stdout=out, stderr=err) + except subprocess.CalledProcessError: + return False + + if output_dir: + os.replace(tmp_dir, output_dir) + + return True + + def run(self, commit_hash, tmp_dir, output_dir, kdoc_files, is_regression, + is_end): + """Run kernel-doc on its several ways""" + if not kdoc_files: + raise RuntimeError("No kernel-doc references found") + + git_helper = GitHelper(self.work_dir) + if not git_helper.checkout_commit(commit_hash, quiet=True): + raise RuntimeError(f"ERROR: can't checkout commit {commit_hash}") + + print(f"Processing {commit_hash}...") + + if not is_regression: + return self.normal_run(tmp_dir, output_dir, kdoc_files) + + yaml_file = os.path.join(tmp_dir, YAML_NAME) + + if not is_end: + return self.gen_yaml(yaml_file, kdoc_files) + + return self.run_unittest(yaml_file) + +class DiffManager: + """Compare documentation output directories with an external diff.""" + def __init__(self, diff_tool="diff", diff_args=None): + self.diff_tool = diff_tool + # default: unified, no context, ignore whitespace changes + self.diff_args = diff_args or ["-u0", "-w"] + + def diff_directories(self, dir1, dir2): + """Compare two directories using an external diff.""" + print(f"\nDiffing {dir1} and {dir2}:") + + dir1_files = set() + dir2_files = set() + has_diff = False + + for root, _, files in os.walk(dir1): + for file in files: + dir1_files.add(os.path.relpath(os.path.join(root, file), dir1)) + for root, _, files in os.walk(dir2): + for file in files: + dir2_files.add(os.path.relpath(os.path.join(root, file), dir2)) + + common_files = sorted(dir1_files & dir2_files) + for file in common_files: + f1 = os.path.join(dir1, file) + f2 = os.path.join(dir2, file) + + cmd = [self.diff_tool] + self.diff_args + [f1, f2] + try: + result = subprocess.run( + cmd, capture_output=True, text=True, check=False + ) + if result.stdout: + has_diff = True + print(f"\n{file}") + print(result.stdout, end="") + except FileNotFoundError: + print(f"ERROR: {self.diff_tool} not found") + sys.exit(1) + + # Show files that exist only in one directory + only_in_dir1 = dir1_files - dir2_files + only_in_dir2 = dir2_files - dir1_files + if only_in_dir1 or only_in_dir2: + has_diff = True + print("\nDifferential files:") + for f in sorted(only_in_dir1): + print(f" - {f} (only in {dir1})") + for f in sorted(only_in_dir2): + print(f" + {f} (only in {dir2})") + + if not has_diff: + print("\nNo differences between those two commits") + + +class SignalHandler(): + """Signal handler class.""" + + def restore(self, force_exit=False): + """Restore original HEAD state.""" + if self.restored: + return + + print(f"Restoring original branch: {self.original_head}") + try: + subprocess.check_call( + ["git", "checkout", "-f", self.original_head], + cwd=self.git_helper.work_dir, + stderr=subprocess.STDOUT, + ) + except subprocess.CalledProcessError as e: + print(f"Failed to restore: {e}", file=sys.stderr) + + for sig, handler in self.old_handler.items(): + signal.signal(sig, handler) + + self.restored = True + + if force_exit: + sys.exit(1) + + def signal_handler(self, sig, _): + """Handle interrupt signals.""" + print(f"\nSignal {sig} received. Restoring original state...") + + self.restore(force_exit=True) + + def __enter__(self): + """Allow using it via with command.""" + for sig in [signal.SIGINT, signal.SIGTERM]: + self.old_handler[sig] = signal.getsignal(sig) + signal.signal(sig, self.signal_handler) + + return self + + def __exit__(self, *args): + """Restore signals at the end of with block.""" + self.restore() + + def __init__(self, git_helper, original_head): + self.git_helper = git_helper + self.original_head = original_head + self.old_handler = {} + self.restored = False + +def parse_commit_range(value): + """Handle a commit range.""" + if ".." not in value: + begin = value + end = "HEAD" + else: + begin, _, end = value.partition("..") + if not end: + end = "HEAD" + + if not begin: + raise argparse.ArgumentTypeError("Need a commit begginning") + + + print(f"Range: {begin} to {end}") + + return begin, end + + +def main(): + """Main code""" + parser = argparse.ArgumentParser(description="Compare kernel documentation between commits") + parser.add_argument("commits", type=parse_commit_range, + help="commit range like old..new") + parser.add_argument("files", nargs="*", + help="files to process – if supplied the --full flag is ignored") + + parser.add_argument("--full", "-f", action="store_true", + help="Force a full scan of Documentation/*") + + parser.add_argument("--regression", "-r", action="store_true", + help="Use YAML format to check for regressions") + + parser.add_argument("--work-dir", "-w", default=WORK_DIR, + help="work dir (default: %(default)s)") + + parser.add_argument("--clean", "-c", action="store_true", + help="Clean caches") + + args = parser.parse_args() + + if args.files and args.full: + raise argparse.ArgumentError(args.full, + "cannot combine '--full' with an explicit file list") + + work_dir = os.path.abspath(args.work_dir) + + # Initialize cache + cache = CacheManager(work_dir) + cache.initialize() + + # Validate git repository + git_helper = GitHelper(work_dir) + if not git_helper.is_inside_repository(): + raise RuntimeError("Must run inside Git repository") + + old_commit, new_commit = args.commits + + old_commit = git_helper.get_short_hash(old_commit) + new_commit = git_helper.get_short_hash(new_commit) + + # Validate commits + for commit in [old_commit, new_commit]: + if not git_helper.is_valid_commit(commit): + raise RuntimeError(f"Commit '{commit}' does not exist") + + # Check for uncommitted changes + if git_helper.has_uncommitted_changes(): + raise RuntimeError("Uncommitted changes present. Commit or stash first.") + + runner = KernelDocRunner(git_helper.work_dir, KDOC_BINARY) + + # Get files to be parsed + cache_msg = " (results will be cached)" + if args.full: + kdoc_files = ["."] + diff_type = "full" + print(f"Parsing all files at {work_dir}") + if not args.files: + diff_type = "partial" + kdoc_files = runner.find_kdoc_references() + print(f"Parsing files with kernel-doc markups at {work_dir}/Documentation") + else: + diff_type = "no-cache" + cache_msg = "" + kdoc_files = args.files + + tmp_dir = DIR_NAME["tmp"] + out_path = DIR_NAME[diff_type] + + if not args.regression: + print(f"Output will be stored at: {out_path}{cache_msg}") + + # Just in case - should never happen in practice + if not kdoc_files: + raise argparse.ArgumentError(args.files, + "No kernel-doc references found") + + original_head = git_helper.get_current_branch() + + old_cache = cache.get_commit_cache(old_commit, out_path) + new_cache = cache.get_commit_cache(new_commit, out_path) + + with SignalHandler(git_helper, original_head): + if args.clean or diff_type == "no-cache": + for cache_dir in [old_cache, new_cache]: + if cache_dir and os.path.exists(cache_dir): + shutil.rmtree(cache_dir) + + if args.regression or not os.path.exists(old_cache): + old_success = runner.run(old_commit, tmp_dir, old_cache, kdoc_files, + args.regression, False) + else: + old_success = True + + if args.regression or not os.path.exists(new_cache): + new_success = runner.run(new_commit, tmp_dir, new_cache, kdoc_files, + args.regression, True) + else: + new_success = True + + if not (old_success and new_success): + raise RuntimeError("Failed to generate documentation") + + if not args.regression: + diff_manager = DiffManager() + diff_manager.diff_directories(old_cache, new_cache) + +if __name__ == "__main__": + main() diff --git a/tools/docs/kernel-doc b/tools/docs/kernel-doc index aed09f9a54dd..d9192c3f1645 100755 --- a/tools/docs/kernel-doc +++ b/tools/docs/kernel-doc @@ -210,7 +210,6 @@ def main(): help="Enable debug messages") parser.add_argument("-M", "-modulename", "--modulename", - default="Kernel API", help="Allow setting a module name at the output.") parser.add_argument("-l", "-enable-lineno", "--enable_lineno", @@ -241,11 +240,9 @@ def main(): help=EXPORT_FILE_DESC) # - # Output format mutually-exclusive group + # Output format # - out_group = parser.add_argument_group("Output format selection (mutually exclusive)") - - out_fmt = out_group.add_mutually_exclusive_group() + out_fmt = parser.add_argument_group("Output format selection (mutually exclusive)") out_fmt.add_argument("-m", "-man", "--man", action="store_true", help="Output troff manual page format.") @@ -254,6 +251,12 @@ def main(): out_fmt.add_argument("-N", "-none", "--none", action="store_true", help="Do not output documentation, only warnings.") + out_fmt.add_argument("-y", "--yaml-file", "--yaml", + help="Stores kernel-doc output on a yaml file.") + out_fmt.add_argument("-k", "--kdoc-item", "--kdoc", action="store_true", + help="Store KdocItem inside yaml file. Ued together with --yaml.") + + # # Output selection mutually-exclusive group # @@ -324,14 +327,42 @@ def main(): from kdoc.kdoc_files import KernelFiles # pylint: disable=C0415 from kdoc.kdoc_output import RestFormat, ManFormat # pylint: disable=C0415 - if args.man: - out_style = ManFormat(modulename=args.modulename) - elif args.none: + yaml_content = set() + if args.yaml_file: out_style = None + + if args.man: + yaml_content |= {"man"} + + if args.rst: + yaml_content |= {"rst"} + + if args.kdoc_item or not yaml_content: + yaml_content |= {"KdocItem"} + else: - out_style = RestFormat() + n_outputs = 0 + + if args.man: + out_style = ManFormat(modulename=args.modulename) + n_outputs += 1 + + if args.none: + out_style = None + n_outputs += 1 + + if args.rst or n_outputs == 0: + n_outputs += 1 + out_style = RestFormat() + + if n_outputs > 1: + parser.error("Those arguments are muttually exclusive: --man, --rst, --none, except when generating a YAML file.") + + elif not n_outputs: + out_style = RestFormat() kfiles = KernelFiles(verbose=args.verbose, + yaml_file=args.yaml_file, yaml_content=yaml_content, out_style=out_style, werror=args.werror, wreturn=args.wreturn, wshort_desc=args.wshort_desc, wcontents_before_sections=args.wcontents_before_sections) diff --git a/tools/docs/sphinx-build-wrapper b/tools/docs/sphinx-build-wrapper index b7c149dff06b..1bb962202784 100755 --- a/tools/docs/sphinx-build-wrapper +++ b/tools/docs/sphinx-build-wrapper @@ -238,7 +238,12 @@ class SphinxBuilder: self.latexopts = os.environ.get("LATEXOPTS", "") if not verbose: - verbose = bool(os.environ.get("KBUILD_VERBOSE", "") != "") + try: + verbose = bool(int(os.environ.get("KBUILD_VERBOSE", 0))) + except ValueError: + # Handles an eventual case where verbosity is not a number + # like KBUILD_VERBOSE="" + verbose = False if verbose is not None: self.verbose = verbose @@ -576,7 +581,6 @@ class SphinxBuilder: """ re_kernel_doc = re.compile(r"^\.\.\s+kernel-doc::\s*(\S+)") - re_man = re.compile(r'^\.TH "[^"]*" (\d+) "([^"]*)"') if docs_dir == src_dir: # @@ -616,8 +620,7 @@ class SphinxBuilder: fp = None try: for line in result.stdout.split("\n"): - match = re_man.match(line) - if not match: + if not line.startswith(".TH"): if fp: fp.write(line + '\n') continue @@ -625,7 +628,11 @@ class SphinxBuilder: if fp: fp.close() - fname = f"{output_dir}/{match.group(2)}.{match.group(1)}" + # Use shlex here, as it handles well parameters with commas + args = shlex.split(line) + fname = f"{args[1]}.{args[2]}" + fname = fname.replace("/", " ") + fname = f"{output_dir}/{fname}" if self.verbose: print(f"Creating {fname}") |
