All of lore.kernel.org
 help / color / mirror / Atom feed
* [RFC] devtools: replace checkpatches shell wrapper with Python script
@ 2026-01-31 21:56 Stephen Hemminger
  2026-09-07 16:57 ` [PATCH v5] devtools: add Python-based patch style checker Stephen Hemminger
  0 siblings, 1 reply; 4+ messages in thread
From: Stephen Hemminger @ 2026-01-31 21:56 UTC (permalink / raw)
  To: dev; +Cc: Stephen Hemminger

Replace the checkpatches.sh shell script that wraps the Linux kernel's
checkpatch.pl with a standalone Python implementation. The new
checkpatches.py script provides the same functionality without
requiring the kernel source tree or Perl.

As a single python script it is an order of magnitude faster than
the old perl and shell script with lots of awk. Running on a
recent patch set for flow parser took 2m23s and in python
toke .43s; a 300x speedup!

Key features include:

- Patch format validation and Signed-off-by checking
- Line length, trailing whitespace, and coding style checks
- Kernel checkpatch.pl compatible checks:
  - AVOID_EXTERNS: externs in .c files
  - UNNECESSARY_BREAK: break after return/goto/continue
  - COMPLEX_MACRO: macros with compound literals
  - STRNCPY: prefer strlcpy over strncpy
  - TYPO_SPELLING: spell checking via codespell
- All DPDK-specific forbidden token checks:
  - RTE_LOG usage (prefer RTE_LOG_LINE)
  - printf/fprintf to stdout/stderr in libs/drivers
  - rte_panic/rte_exit usage
  - Direct __attribute__ usage
  - Atomic builtins (__sync_xxx, __atomic_xxx)
  - pthread functions (prefer rte_thread)
  - And many more
- Tag placement checks (__rte_experimental, __rte_internal)
- Alignment and packed attribute validation
- Git integration for checking commits by range or count

This script was generated using the Claude AI assistant by providing it
with the original checkpatches.sh and checkpatch.pl scripts and asking
it to create a standalone Python replacement.

The script is still a work in progress because it seems
to not find everything right now. If you have examples where
it is not matching on output, can correct that.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 devtools/checkpatches.py | 873 +++++++++++++++++++++++++++++++++++++++
 1 file changed, 873 insertions(+)
 create mode 100755 devtools/checkpatches.py

diff --git a/devtools/checkpatches.py b/devtools/checkpatches.py
new file mode 100755
index 0000000000..bdb30828cf
--- /dev/null
+++ b/devtools/checkpatches.py
@@ -0,0 +1,873 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright 2015 6WIND S.A.
+# Copyright 2025 - Python rewrite
+#
+# checkpatch.py - Check patches for common style issues
+#
+# This is a standalone Python replacement for the DPDK checkpatches.sh
+# script that previously wrapped the Linux kernel's checkpatch.pl.
+
+import argparse
+import os
+import re
+import subprocess
+import sys
+import tempfile
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+
+VERSION = "1.0"
+
+# Default configuration
+DEFAULT_LINE_LENGTH = 100
+DEFAULT_CODESPELL_DICT = "/usr/share/codespell/dictionary.txt"
+
+
+@dataclass
+class CheckResult:
+    """Result of a single check."""
+    level: str  # ERROR, WARNING, CHECK
+    type_name: str
+    message: str
+    filename: str = ""
+    line_num: int = 0
+    line_content: str = ""
+
+
+@dataclass
+class PatchInfo:
+    """Information extracted from a patch."""
+    subject: str = ""
+    author: str = ""
+    author_email: str = ""
+    signoffs: list = field(default_factory=list)
+    files: list = field(default_factory=list)
+    added_lines: dict = field(default_factory=dict)  # filename -> [(line_num, content)]
+    has_fixes_tag: bool = False
+    fixes_commits: list = field(default_factory=list)
+
+
+class CheckPatch:
+    """Main class for checking patches."""
+
+    def __init__(self, config: dict):
+        self.config = config
+        self.results: list[CheckResult] = []
+        self.errors = 0
+        self.warnings = 0
+        self.checks = 0
+        self.lines_checked = 0
+
+        # Load codespell dictionary if enabled
+        self.spelling_dict = {}
+        if config.get("codespell"):
+            self._load_codespell_dict()
+
+        # DPDK-specific ignore list (matches original shell script)
+        self.ignored_types = set([
+            "LINUX_VERSION_CODE", "ENOSYS", "FILE_PATH_CHANGES",
+            "MAINTAINERS_STYLE", "SPDX_LICENSE_TAG", "VOLATILE",
+            "PREFER_PACKED", "PREFER_ALIGNED", "PREFER_PRINTF", "STRLCPY",
+            "PREFER_KERNEL_TYPES", "PREFER_FALLTHROUGH", "BIT_MACRO",
+            "CONST_STRUCT", "SPLIT_STRING", "LONG_LINE_STRING",
+            "C99_COMMENT_TOLERANCE", "LINE_SPACING", "PARENTHESIS_ALIGNMENT",
+            "NETWORKING_BLOCK_COMMENT_STYLE", "NEW_TYPEDEFS",
+            "COMPARISON_TO_NULL", "AVOID_BUG", "EXPORT_SYMBOL",
+            "BAD_REPORTED_BY_LINK"
+        ])
+
+        # Forbidden token rules for DPDK
+        self.forbidden_rules = self._init_forbidden_rules()
+
+    def _load_codespell_dict(self) -> None:
+        """Load the codespell dictionary."""
+        dict_path = self.config.get("codespell_file")
+
+        if not dict_path:
+            # Search common locations for the dictionary
+            search_paths = [
+                DEFAULT_CODESPELL_DICT,
+                "/usr/local/lib/python3.12/dist-packages/codespell_lib/data/dictionary.txt",
+                "/usr/local/lib/python3.11/dist-packages/codespell_lib/data/dictionary.txt",
+                "/usr/local/lib/python3.10/dist-packages/codespell_lib/data/dictionary.txt",
+                "/usr/lib/python3/dist-packages/codespell_lib/data/dictionary.txt",
+            ]
+
+            # Also try to find it via codespell module
+            try:
+                import codespell_lib
+                module_path = os.path.join(
+                    os.path.dirname(codespell_lib.__file__),
+                    'data', 'dictionary.txt'
+                )
+                search_paths.insert(0, module_path)
+            except ImportError:
+                pass
+
+            for path in search_paths:
+                if os.path.exists(path):
+                    dict_path = path
+                    break
+
+        if not dict_path or not os.path.exists(dict_path):
+            return
+
+        try:
+            with open(dict_path, "r", encoding="utf-8", errors="ignore") as f:
+                for line in f:
+                    line = line.strip()
+                    if not line or line.startswith("#"):
+                        continue
+                    parts = line.split("->")
+                    if len(parts) >= 2:
+                        wrong = parts[0].strip().lower()
+                        correct = parts[1].strip().split(",")[0].strip()
+                        self.spelling_dict[wrong] = correct
+        except IOError:
+            pass
+
+    def _init_forbidden_rules(self) -> list:
+        """Initialize DPDK-specific forbidden token rules."""
+        return [
+            # Refrain from new calls to RTE_LOG in libraries
+            {
+                "folders": ["lib"],
+                "patterns": [r"RTE_LOG\("],
+                "message": "Prefer RTE_LOG_LINE",
+            },
+            # Refrain from new calls to RTE_LOG in drivers
+            {
+                "folders": ["drivers"],
+                "skip_files": [r".*osdep\.h$"],
+                "patterns": [r"RTE_LOG\(", r"RTE_LOG_DP\(", r"rte_log\("],
+                "message": "Prefer RTE_LOG_LINE/RTE_LOG_DP_LINE",
+            },
+            # No output on stdout or stderr
+            {
+                "folders": ["lib", "drivers"],
+                "patterns": [r"\bprintf\b", r"fprintf\(stdout,", r"fprintf\(stderr,"],
+                "message": "Writing to stdout or stderr",
+            },
+            # Refrain from rte_panic() and rte_exit()
+            {
+                "folders": ["lib", "drivers"],
+                "patterns": [r"rte_panic\(", r"rte_exit\("],
+                "message": "Using rte_panic/rte_exit",
+            },
+            # Don't call directly install_headers()
+            {
+                "folders": ["lib", "drivers"],
+                "patterns": [r"\binstall_headers\b"],
+                "message": "Using install_headers()",
+            },
+            # Refrain from using compiler attribute without common macro
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "skip_files": [r"lib/eal/include/rte_common\.h"],
+                "patterns": [r"__attribute__"],
+                "message": "Using compiler attribute directly",
+            },
+            # Check %l or %ll format specifier
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "patterns": [r"%ll*[xud]"],
+                "message": "Using %l format, prefer %PRI*64 if type is [u]int64_t",
+            },
+            # Refrain from 16/32/64 bits rte_atomicNN_xxx()
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "patterns": [r"rte_atomic[0-9][0-9]_.*\("],
+                "message": "Using rte_atomicNN_xxx",
+            },
+            # Refrain from rte_smp_[r/w]mb()
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "patterns": [r"rte_smp_(r|w)?mb\("],
+                "message": "Using rte_smp_[r/w]mb",
+            },
+            # Refrain from __sync_xxx builtins
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "patterns": [r"__sync_.*\("],
+                "message": "Using __sync_xxx builtins",
+            },
+            # Refrain from __rte_atomic_thread_fence()
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "patterns": [r"__rte_atomic_thread_fence\("],
+                "message": "Using __rte_atomic_thread_fence, prefer rte_atomic_thread_fence",
+            },
+            # Refrain from __atomic_xxx builtins
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "skip_files": [r"drivers/common/cnxk/"],
+                "patterns": [r"__atomic_.*\(", r"__ATOMIC_(RELAXED|CONSUME|ACQUIRE|RELEASE|ACQ_REL|SEQ_CST)"],
+                "message": "Using __atomic_xxx/__ATOMIC_XXX built-ins, prefer rte_atomic_xxx/rte_memory_order_xxx",
+            },
+            # Refrain from some pthread functions
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "patterns": [r"pthread_(create|join|detach|set(_?name_np|affinity_np)|attr_set(inheritsched|schedpolicy))\("],
+                "message": "Using pthread functions, prefer rte_thread",
+            },
+            # Forbid use of __reserved
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "patterns": [r"\b__reserved\b"],
+                "message": "Using __reserved",
+            },
+            # Forbid use of __alignof__
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "patterns": [r"\b__alignof__\b"],
+                "message": "Using __alignof__, prefer C11 alignof",
+            },
+            # Forbid use of __typeof__
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "patterns": [r"\b__typeof__\b"],
+                "message": "Using __typeof__, prefer typeof",
+            },
+            # Forbid use of __builtin_*
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "skip_files": [r"lib/eal/", r"drivers/.*/base/", r"drivers/.*osdep\.h$"],
+                "patterns": [r"\b__builtin_"],
+                "message": "Using __builtin helpers, prefer EAL macros",
+            },
+            # Forbid inclusion of linux/pci_regs.h
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "patterns": [r"include.*linux/pci_regs\.h"],
+                "message": "Using linux/pci_regs.h, prefer rte_pci.h",
+            },
+            # Forbid variadic argument pack extension in macros
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "patterns": [r"#\s*define.*[^(,\s]\.\.\.[\s]*\)"],
+                "message": "Do not use variadic argument pack in macros",
+            },
+            # Forbid __rte_packed_begin with enums
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "patterns": [r"enum.*__rte_packed_begin"],
+                "message": "Using __rte_packed_begin with enum is not allowed",
+            },
+            # Forbid use of #pragma
+            {
+                "folders": ["lib", "drivers", "app", "examples"],
+                "skip_files": [r"lib/eal/include/rte_common\.h"],
+                "patterns": [r"(#pragma|_Pragma)"],
+                "message": "Using compilers pragma is not allowed",
+            },
+            # Forbid experimental build flag except in examples
+            {
+                "folders": ["lib", "drivers", "app"],
+                "patterns": [r"-DALLOW_EXPERIMENTAL_API", r"allow_experimental_apis"],
+                "message": "Using experimental build flag for in-tree compilation",
+            },
+            # Refrain from using RTE_LOG_REGISTER for drivers and libs
+            {
+                "folders": ["lib", "drivers"],
+                "patterns": [r"\bRTE_LOG_REGISTER\b"],
+                "message": "Using RTE_LOG_REGISTER, prefer RTE_LOG_REGISTER_(DEFAULT|SUFFIX)",
+            },
+            # Forbid non-internal thread in drivers and libs
+            {
+                "folders": ["lib", "drivers"],
+                "patterns": [r"rte_thread_(set_name|create_control)\("],
+                "message": "Prefer rte_thread_(set_prefixed_name|create_internal_control)",
+            },
+        ]
+
+    def add_result(self, level: str, type_name: str, message: str,
+                   filename: str = "", line_num: int = 0, line_content: str = "") -> None:
+        """Add a check result."""
+        if type_name.upper() in self.ignored_types:
+            return
+
+        result = CheckResult(
+            level=level,
+            type_name=type_name,
+            message=message,
+            filename=filename,
+            line_num=line_num,
+            line_content=line_content
+        )
+        self.results.append(result)
+
+        if level == "ERROR":
+            self.errors += 1
+        elif level == "WARNING":
+            self.warnings += 1
+        else:
+            self.checks += 1
+
+    def parse_patch(self, content: str) -> PatchInfo:
+        """Parse a patch and extract information."""
+        info = PatchInfo()
+        current_file = ""
+        in_diff = False
+        line_num_in_new = 0
+
+        lines = content.split("\n")
+        for i, line in enumerate(lines):
+            # Extract subject
+            if line.startswith("Subject:"):
+                subject = line[8:].strip()
+                # Handle multi-line subjects
+                j = i + 1
+                while j < len(lines) and lines[j].startswith(" "):
+                    subject += " " + lines[j].strip()
+                    j += 1
+                info.subject = subject
+
+            # Extract author
+            if line.startswith("From:"):
+                info.author = line[5:].strip()
+                match = re.search(r"<([^>]+)>", info.author)
+                if match:
+                    info.author_email = match.group(1)
+
+            # Extract Signed-off-by
+            match = re.match(r"^Signed-off-by:\s*(.+)$", line, re.IGNORECASE)
+            if match:
+                info.signoffs.append(match.group(1).strip())
+
+            # Extract Fixes tag
+            match = re.match(r"^Fixes:\s*([0-9a-fA-F]+)", line)
+            if match:
+                info.has_fixes_tag = True
+                info.fixes_commits.append(match.group(1))
+
+            # Track files in diff
+            if line.startswith("diff --git"):
+                match = re.match(r"diff --git a/(\S+) b/(\S+)", line)
+                if match:
+                    current_file = match.group(2)
+                    if current_file not in info.files:
+                        info.files.append(current_file)
+                    info.added_lines[current_file] = []
+                in_diff = True
+
+            # Track hunks
+            if line.startswith("@@"):
+                match = re.match(r"@@ -\d+(?:,\d+)? \+(\d+)", line)
+                if match:
+                    line_num_in_new = int(match.group(1))
+                continue
+
+            # Track added lines
+            if in_diff and current_file:
+                if line.startswith("+") and not line.startswith("+++"):
+                    info.added_lines[current_file].append((line_num_in_new, line[1:]))
+                    line_num_in_new += 1
+                elif line.startswith("-"):
+                    pass  # Deleted line, don't increment
+                elif not line.startswith("\\"):
+                    line_num_in_new += 1
+
+        return info
+
+    def check_line_length(self, patch_info: PatchInfo) -> None:
+        """Check for lines exceeding maximum length."""
+        max_len = self.config.get("max_line_length", DEFAULT_LINE_LENGTH)
+
+        for filename, lines in patch_info.added_lines.items():
+            for line_num, content in lines:
+                # Skip strings that span multiple lines
+                if len(content) > max_len:
+                    # Don't warn about long strings or URLs
+                    if '\"' in content and content.count('\"') >= 2:
+                        continue
+                    if "http://" in content or "https://" in content:
+                        continue
+                    self.add_result(
+                        "WARNING", "LONG_LINE",
+                        f"line length of {len(content)} exceeds {max_len} columns",
+                        filename, line_num, content
+                    )
+
+    def check_trailing_whitespace(self, patch_info: PatchInfo) -> None:
+        """Check for trailing whitespace."""
+        for filename, lines in patch_info.added_lines.items():
+            for line_num, content in lines:
+                if content != content.rstrip():
+                    self.add_result(
+                        "WARNING", "TRAILING_WHITESPACE",
+                        "trailing whitespace",
+                        filename, line_num, content
+                    )
+
+    def check_tabs_spaces(self, patch_info: PatchInfo) -> None:
+        """Check for space before tab and mixed indentation."""
+        for filename, lines in patch_info.added_lines.items():
+            for line_num, content in lines:
+                if " \t" in content:
+                    self.add_result(
+                        "WARNING", "SPACE_BEFORE_TAB",
+                        "space before tab in indent",
+                        filename, line_num, content
+                    )
+
+    def check_signoff(self, patch_info: PatchInfo) -> None:
+        """Check for Signed-off-by line."""
+        if not patch_info.signoffs:
+            self.add_result(
+                "ERROR", "MISSING_SIGN_OFF",
+                "Missing Signed-off-by: line(s)"
+            )
+
+    def check_coding_style(self, patch_info: PatchInfo) -> None:
+        """Check various coding style issues."""
+        for filename, lines in patch_info.added_lines.items():
+            # Skip non-C files for most checks
+            is_c_file = filename.endswith((".c", ".h"))
+            is_c_source = filename.endswith(".c")
+
+            prev_line = ""
+            for line_num, content in lines:
+                self.lines_checked += 1
+
+                if is_c_file:
+                    # Check for externs in .c files
+                    if is_c_source and re.match(r"^\s*extern\b", content):
+                        self.add_result(
+                            "WARNING", "AVOID_EXTERNS",
+                            "externs should be avoided in .c files",
+                            filename, line_num, content
+                        )
+
+                    # Check for unnecessary break after goto/return/continue
+                    if re.match(r"^\s*break\s*;", content):
+                        if re.match(r"^\s*(goto|return|continue)\b", prev_line):
+                            self.add_result(
+                                "WARNING", "UNNECESSARY_BREAK",
+                                "break is not useful after a goto or return",
+                                filename, line_num, content
+                            )
+
+                    # Check for strncpy usage - prefer strlcpy
+                    if re.search(r"\bstrncpy\s*\(", content):
+                        self.add_result(
+                            "WARNING", "STRNCPY",
+                            "Prefer strlcpy over strncpy - see: https://lore.kernel.org/r/CAHk-=wgfRnXz0W3D37d01q3JFkr_i_uTL=V6A6G1oUZcprmknw@mail.gmail.com/",
+                            filename, line_num, content
+                        )
+
+                    # Check for complex macros without proper enclosure
+                    if re.match(r"^\s*#\s*define\s+\w+\s*\([^)]*\)\s+\(", content):
+                        # Macro with arguments that starts with ( - check if it's a compound literal
+                        if re.search(r"\)\s+\([^)]*\]\s*\)\s*\{", content) or \
+                           re.search(r"\)\s+\(const\s+", content) or \
+                           re.search(r"\)\s+\(enum\s+", content) or \
+                           re.search(r"\)\s+\(struct\s+", content):
+                            self.add_result(
+                                "ERROR", "COMPLEX_MACRO",
+                                "Macros with complex values should be enclosed in parentheses",
+                                filename, line_num, content
+                            )
+
+                    # Check for spaces around operators
+                    # if=( instead of if (
+                    if re.search(r"\b(if|while|for|switch)\(", content):
+                        self.add_result(
+                            "WARNING", "SPACING",
+                            "space required before the open parenthesis '('",
+                            filename, line_num, content
+                        )
+
+                    # Check for brace placement (K&R style)
+                    if re.match(r"^\s*{$", content):
+                        # Opening brace on its own line (after function def is OK)
+                        pass
+
+                    # Multiple statements on one line
+                    if re.search(r";\s*[a-zA-Z]", content) and "for" not in content:
+                        self.add_result(
+                            "CHECK", "MULTIPLE_STATEMENTS",
+                            "multiple statements on one line",
+                            filename, line_num, content
+                        )
+
+                    # Check for C99 comments in headers that should use C89
+                    if filename.endswith(".h") and "//" in content:
+                        # Only flag if not in a string
+                        stripped = re.sub(r'"[^"]*"', '', content)
+                        if "//" in stripped:
+                            self.add_result(
+                                "CHECK", "C99_COMMENTS",
+                                "C99 // comments are acceptable but /* */ is preferred in headers",
+                                filename, line_num, content
+                            )
+
+                prev_line = content
+
+    def check_spelling(self, patch_info: PatchInfo) -> None:
+        """Check for spelling errors using codespell dictionary."""
+        if not self.spelling_dict:
+            return
+
+        for filename, lines in patch_info.added_lines.items():
+            for line_num, content in lines:
+                # Extract words from the line
+                words = re.findall(r'\b[a-zA-Z]+\b', content)
+                for word in words:
+                    lower_word = word.lower()
+                    if lower_word in self.spelling_dict:
+                        self.add_result(
+                            "WARNING", "TYPO_SPELLING",
+                            f"'{word}' may be misspelled - perhaps '{self.spelling_dict[lower_word]}'?",
+                            filename, line_num, content
+                        )
+
+    def check_forbidden_tokens(self, patch_info: PatchInfo) -> None:
+        """Check for DPDK-specific forbidden tokens."""
+        for filename, lines in patch_info.added_lines.items():
+            for rule in self.forbidden_rules:
+                # Check if file is in one of the target folders
+                in_folder = False
+                for folder in rule["folders"]:
+                    if filename.startswith(folder + "/") or filename.startswith("b/" + folder + "/"):
+                        in_folder = True
+                        break
+
+                if not in_folder:
+                    continue
+
+                # Check if file should be skipped
+                skip = False
+                for skip_pattern in rule.get("skip_files", []):
+                    if re.search(skip_pattern, filename):
+                        skip = True
+                        break
+
+                if skip:
+                    continue
+
+                # Check each line for forbidden patterns
+                for line_num, content in lines:
+                    for pattern in rule["patterns"]:
+                        if re.search(pattern, content):
+                            self.add_result(
+                                "WARNING", "FORBIDDEN_TOKEN",
+                                rule["message"],
+                                filename, line_num, content
+                            )
+                            break
+
+    def check_experimental_tags(self, patch_info: PatchInfo) -> None:
+        """Check __rte_experimental tag placement."""
+        for filename, lines in patch_info.added_lines.items():
+            for line_num, content in lines:
+                if "__rte_experimental" in content:
+                    # Should only be in headers
+                    if filename.endswith(".c"):
+                        self.add_result(
+                            "WARNING", "EXPERIMENTAL_TAG",
+                            f"Please only put __rte_experimental tags in headers ({filename})",
+                            filename, line_num, content
+                        )
+                    # Should appear alone on the line
+                    stripped = content.strip()
+                    if stripped != "__rte_experimental":
+                        self.add_result(
+                            "WARNING", "EXPERIMENTAL_TAG",
+                            "__rte_experimental must appear alone on the line immediately preceding the return type of a function",
+                            filename, line_num, content
+                        )
+
+    def check_internal_tags(self, patch_info: PatchInfo) -> None:
+        """Check __rte_internal tag placement."""
+        for filename, lines in patch_info.added_lines.items():
+            for line_num, content in lines:
+                if "__rte_internal" in content:
+                    # Should only be in headers
+                    if filename.endswith(".c"):
+                        self.add_result(
+                            "WARNING", "INTERNAL_TAG",
+                            f"Please only put __rte_internal tags in headers ({filename})",
+                            filename, line_num, content
+                        )
+                    # Should appear alone on the line
+                    stripped = content.strip()
+                    if stripped != "__rte_internal":
+                        self.add_result(
+                            "WARNING", "INTERNAL_TAG",
+                            "__rte_internal must appear alone on the line immediately preceding the return type of a function",
+                            filename, line_num, content
+                        )
+
+    def check_aligned_attributes(self, patch_info: PatchInfo) -> None:
+        """Check alignment attribute usage."""
+        align_tokens = ["__rte_aligned", "__rte_cache_aligned", "__rte_cache_min_aligned"]
+
+        for filename, lines in patch_info.added_lines.items():
+            for line_num, content in lines:
+                for token in align_tokens:
+                    if re.search(rf"\b{token}\b", content):
+                        # Should only be used with struct or union
+                        if not re.search(rf"\b(struct|union)\s*{token}\b", content):
+                            self.add_result(
+                                "WARNING", "ALIGNED_ATTRIBUTE",
+                                f"Please use {token} only for struct or union types alignment",
+                                filename, line_num, content
+                            )
+
+    def check_packed_attributes(self, patch_info: PatchInfo) -> None:
+        """Check packed attribute usage."""
+        begin_count = 0
+        end_count = 0
+
+        for filename, lines in patch_info.added_lines.items():
+            for line_num, content in lines:
+                if "__rte_packed_begin" in content:
+                    begin_count += 1
+                    # Should be after struct, union, or alignment attributes
+                    if not re.search(r"\b(struct|union)\s*__rte_packed_begin\b", content) and \
+                       not re.search(r"__rte_cache_aligned\s*__rte_packed_begin", content) and \
+                       not re.search(r"__rte_cache_min_aligned\s*__rte_packed_begin", content) and \
+                       not re.search(r"__rte_aligned\(.*\)\s*__rte_packed_begin", content):
+                        self.add_result(
+                            "WARNING", "PACKED_ATTRIBUTE",
+                            "Use __rte_packed_begin only after struct, union or alignment attributes",
+                            filename, line_num, content
+                        )
+
+                if "__rte_packed_end" in content:
+                    end_count += 1
+
+        if begin_count != end_count:
+            self.add_result(
+                "WARNING", "PACKED_ATTRIBUTE",
+                "__rte_packed_begin and __rte_packed_end should always be used in pairs"
+            )
+
+    def check_patch(self, content: str) -> bool:
+        """Run all checks on a patch."""
+        self.results = []
+        self.errors = 0
+        self.warnings = 0
+        self.checks = 0
+        self.lines_checked = 0
+
+        patch_info = self.parse_patch(content)
+
+        # Run all checks
+        self.check_signoff(patch_info)
+        self.check_line_length(patch_info)
+        self.check_trailing_whitespace(patch_info)
+        self.check_tabs_spaces(patch_info)
+        self.check_coding_style(patch_info)
+        self.check_spelling(patch_info)
+        self.check_forbidden_tokens(patch_info)
+        self.check_experimental_tags(patch_info)
+        self.check_internal_tags(patch_info)
+        self.check_aligned_attributes(patch_info)
+        self.check_packed_attributes(patch_info)
+
+        return self.errors == 0 and self.warnings == 0
+
+    def format_results(self, show_types: bool = True) -> str:
+        """Format the results for output."""
+        output = []
+
+        for result in self.results:
+            if result.filename and result.line_num:
+                prefix = f"{result.filename}:{result.line_num}:"
+            elif result.filename:
+                prefix = f"{result.filename}:"
+            else:
+                prefix = ""
+
+            type_str = f" [{result.type_name}]" if show_types else ""
+            output.append(f"{result.level}:{type_str} {result.message}")
+
+            if prefix:
+                output.append(f"#  {prefix}")
+            if result.line_content:
+                output.append(f"+  {result.line_content}")
+            output.append("")
+
+        return "\n".join(output)
+
+    def get_summary(self) -> str:
+        """Get a summary of the check results."""
+        return f"total: {self.errors} errors, {self.warnings} warnings, {self.checks} checks, {self.lines_checked} lines checked"
+
+
+def check_single_patch(checker: CheckPatch, patch_path: Optional[str],
+                       commit: Optional[str], verbose: bool, quiet: bool) -> bool:
+    """Check a single patch file or commit."""
+    subject = ""
+    content = ""
+
+    if patch_path:
+        try:
+            with open(patch_path, "r", encoding="utf-8", errors="replace") as f:
+                content = f.read()
+        except IOError as e:
+            print(f"Error reading {patch_path}: {e}", file=sys.stderr)
+            return False
+    elif commit:
+        try:
+            result = subprocess.run(
+                ["git", "format-patch", "--find-renames", "--no-stat", "--stdout", "-1", commit],
+                capture_output=True,
+                text=True
+            )
+            if result.returncode != 0:
+                print(f"Error getting commit {commit}", file=sys.stderr)
+                return False
+            content = result.stdout
+        except (subprocess.CalledProcessError, FileNotFoundError) as e:
+            print(f"Error running git: {e}", file=sys.stderr)
+            return False
+    else:
+        content = sys.stdin.read()
+
+    # Extract subject
+    match = re.search(r"^Subject:\s*(.+?)(?:\n(?=\S)|\n\n)", content, re.MULTILINE | re.DOTALL)
+    if match:
+        subject = match.group(1).replace("\n ", " ").strip()
+
+    if verbose:
+        print(f"\n### {subject}\n")
+
+    is_clean = checker.check_patch(content)
+    has_issues = checker.errors > 0 or checker.warnings > 0
+
+    if has_issues or verbose:
+        if not verbose and subject:
+            print(f"\n### {subject}\n")
+        print(checker.format_results(show_types=True))
+        print(checker.get_summary())
+
+    return is_clean
+
+
+def parse_args() -> argparse.Namespace:
+    """Parse command line arguments."""
+    parser = argparse.ArgumentParser(
+        description="Check patches for DPDK coding style and common issues",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+  %(prog)s patch.diff                Check a patch file
+  %(prog)s -n 3                      Check last 3 commits
+  %(prog)s -r origin/main..HEAD      Check commits in range
+  cat patch.diff | %(prog)s          Check patch from stdin
+"""
+    )
+
+    parser.add_argument("patches", nargs="*", help="Patch files to check")
+    parser.add_argument("-n", type=int, metavar="NUM",
+                       help="Check last NUM commits")
+    parser.add_argument("-r", "--range", metavar="RANGE",
+                       help="Check commits in git range (default: origin/main..)")
+    parser.add_argument("-q", "--quiet", action="store_true",
+                       help="Quiet mode - only show summary")
+    parser.add_argument("-v", "--verbose", action="store_true",
+                       help="Verbose mode - show all checks")
+    parser.add_argument("--max-line-length", type=int, default=DEFAULT_LINE_LENGTH,
+                       help=f"Maximum line length (default: {DEFAULT_LINE_LENGTH})")
+    parser.add_argument("--codespell", action="store_true", default=True,
+                       help="Enable spell checking (default: enabled)")
+    parser.add_argument("--no-codespell", dest="codespell", action="store_false",
+                       help="Disable spell checking")
+    parser.add_argument("--codespellfile", metavar="FILE",
+                       help="Path to codespell dictionary")
+    parser.add_argument("--show-types", action="store_true", default=True,
+                       help="Show message types (default: enabled)")
+    parser.add_argument("--no-show-types", dest="show_types", action="store_false",
+                       help="Hide message types")
+
+    return parser.parse_args()
+
+
+def main():
+    """Main entry point."""
+    args = parse_args()
+
+    # Build configuration
+    config = {
+        "max_line_length": args.max_line_length,
+        "codespell": args.codespell,
+        "show_types": args.show_types,
+    }
+
+    if args.codespellfile:
+        config["codespell_file"] = args.codespellfile
+
+    checker = CheckPatch(config)
+
+    total = 0
+    failed = 0
+
+    if args.patches:
+        # Check specified patch files
+        for patch in args.patches:
+            total += 1
+            if not check_single_patch(checker, patch, None, args.verbose, args.quiet):
+                failed += 1
+
+    elif args.n or args.range:
+        # Check git commits
+        if args.n:
+            result = subprocess.run(
+                ["git", "rev-list", "--reverse", f"--max-count={args.n}", "HEAD"],
+                capture_output=True,
+                text=True
+            )
+        else:
+            git_range = args.range if args.range else "origin/main.."
+            result = subprocess.run(
+                ["git", "rev-list", "--reverse", git_range],
+                capture_output=True,
+                text=True
+            )
+
+        if result.returncode != 0:
+            print("Error getting git commits", file=sys.stderr)
+            sys.exit(1)
+
+        commits = result.stdout.strip().split("\n")
+        for commit in commits:
+            if commit:
+                total += 1
+                if not check_single_patch(checker, None, commit, args.verbose, args.quiet):
+                    failed += 1
+
+    elif not sys.stdin.isatty():
+        # Read from stdin
+        total = 1
+        if not check_single_patch(checker, None, None, args.verbose, args.quiet):
+            failed += 1
+
+    else:
+        # Default to checking commits since origin/main
+        result = subprocess.run(
+            ["git", "rev-list", "--reverse", "origin/main.."],
+            capture_output=True,
+            text=True
+        )
+
+        commits = result.stdout.strip().split("\n") if result.stdout.strip() else []
+        for commit in commits:
+            if commit:
+                total += 1
+                if not check_single_patch(checker, None, commit, args.verbose, args.quiet):
+                    failed += 1
+
+    # Print summary
+    passed = total - failed
+    if not args.quiet:
+        print(f"\n{passed}/{total} valid patch{'es' if passed != 1 else ''}")
+
+    sys.exit(failed)
+
+
+if __name__ == "__main__":
+    main()
-- 
2.51.0


^ permalink raw reply related	[flat|nested] 4+ messages in thread
* [RFC] devtools: replace get-maintainer shell wrapper with Python script
@ 2026-01-31 20:48 Stephen Hemminger
  2026-02-26 17:15 ` [PATCH v5] devtools: add Python-based patch style checker Stephen Hemminger
  0 siblings, 1 reply; 4+ messages in thread
From: Stephen Hemminger @ 2026-01-31 20:48 UTC (permalink / raw)
  To: dev
  Cc: Stephen Hemminger, Thomas Monjalon, Bruce Richardson,
	Aaron Conole, David Marchand

DPDK has been reusing the Linux kernel get_maintainer perl script
but that creates an unwanted dependency on kernel source.

This new script replaces that with a standalone Python implementation
created in a few minutes with AI. The command line arguments are
a subset of the features that make sense in DPDK.

- Parse MAINTAINERS file with all standard entry types
- Extract modified files from unified diff patches
- Pattern matching for file paths with glob and regex support
- Git history analysis for commit signers and authors
- Email deduplication and .mailmap support
- Compatible command-line interface

A simple get-maintainer.sh wrapper is retained for backward compatibility.

Signed-off-by: Stephen Hemminger <stephen@networkplumber.org>
---
 MAINTAINERS                         |   2 +-
 devtools/get-maintainer.py          | 997 ++++++++++++++++++++++++++++
 devtools/get-maintainer.sh          |  33 +-
 doc/guides/contributing/patches.rst |   4 +-
 4 files changed, 1004 insertions(+), 32 deletions(-)
 create mode 100755 devtools/get-maintainer.py

diff --git a/MAINTAINERS b/MAINTAINERS
index 5683b87e4a..fd90f7da23 100644
--- a/MAINTAINERS
+++ b/MAINTAINERS
@@ -96,7 +96,7 @@ F: devtools/check-git-log.sh
 F: devtools/check-spdx-tag.sh
 F: devtools/check-symbol-change.py
 F: devtools/checkpatches.sh
-F: devtools/get-maintainer.sh
+F: devtools/get-maintainer.*
 F: devtools/git-log-fixes.sh
 F: devtools/load-devel-config
 F: devtools/mailmap-ctl.py
diff --git a/devtools/get-maintainer.py b/devtools/get-maintainer.py
new file mode 100755
index 0000000000..9357206cf5
--- /dev/null
+++ b/devtools/get-maintainer.py
@@ -0,0 +1,997 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2017 Intel Corporation
+# Copyright(c) 2025 - Python rewrite
+#
+# get_maintainer.py - Find maintainers and mailing lists for patches/files
+#
+# Based on the Linux kernel's get_maintainer.pl by Joe Perches
+# and DPDK's get-maintainer.sh wrapper script.
+#
+# Usage: get_maintainer.py [OPTIONS] <patch>
+#        get_maintainer.py [OPTIONS] -f <file>
+
+import argparse
+import os
+import re
+import subprocess
+import sys
+from collections import defaultdict
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+
+VERSION = "1.0"
+
+# Default configuration
+DEFAULT_CONFIG = {
+    "email": True,
+    "email_usename": True,
+    "email_maintainer": True,
+    "email_reviewer": True,
+    "email_fixes": True,
+    "email_list": True,
+    "email_moderated_list": True,
+    "email_subscriber_list": False,
+    "email_git": False,
+    "email_git_all_signature_types": False,
+    "email_git_blame": False,
+    "email_git_blame_signatures": True,
+    "email_git_fallback": True,
+    "email_git_min_signatures": 1,
+    "email_git_max_maintainers": 5,
+    "email_git_min_percent": 5,
+    "email_git_since": "1-year-ago",
+    "email_remove_duplicates": True,
+    "email_use_mailmap": True,
+    "output_multiline": True,
+    "output_separator": ", ",
+    "output_roles": False,
+    "output_rolestats": True,
+    "output_section_maxlen": 50,
+    "scm": False,
+    "web": False,
+    "bug": False,
+    "subsystem": False,
+    "status": False,
+    "keywords": True,
+    "keywords_in_file": False,
+    "sections": False,
+    "email_file_emails": False,
+    "from_filename": False,
+    "pattern_depth": 0,
+}
+
+# Signature tags for git commit analysis
+SIGNATURE_TAGS = [
+    "Signed-off-by:",
+    "Reviewed-by:",
+    "Acked-by:",
+]
+
+
+@dataclass
+class MaintainerEntry:
+    """Represents a maintainer/list entry with role information."""
+    email: str
+    role: str = ""
+
+    def __hash__(self):
+        return hash(self.email.lower())
+
+    def __eq__(self, other):
+        if isinstance(other, MaintainerEntry):
+            return self.email.lower() == other.email.lower()
+        return False
+
+
+@dataclass
+class Section:
+    """Represents a MAINTAINERS file section."""
+    name: str
+    maintainers: list = field(default_factory=list)
+    reviewers: list = field(default_factory=list)
+    mailing_lists: list = field(default_factory=list)
+    status: str = ""
+    files: list = field(default_factory=list)
+    excludes: list = field(default_factory=list)
+    scm: list = field(default_factory=list)
+    web: list = field(default_factory=list)
+    bug: list = field(default_factory=list)
+    keywords: list = field(default_factory=list)
+    regex_patterns: list = field(default_factory=list)
+
+
+class GetMaintainer:
+    """Main class for finding maintainers."""
+
+    def __init__(self, config: dict):
+        self.config = config
+        self.sections: list[Section] = []
+        self.mailmap: dict = {"names": {}, "addresses": {}}
+        self.ignore_emails: list[str] = []
+        self.vcs_type: Optional[str] = None
+        self.root_path = self._find_root_path()
+
+        # Results
+        self.email_to: list[MaintainerEntry] = []
+        self.list_to: list[MaintainerEntry] = []
+        self.scm_list: list[str] = []
+        self.web_list: list[str] = []
+        self.bug_list: list[str] = []
+        self.subsystem_list: list[str] = []
+        self.status_list: list[str] = []
+
+        # Deduplication tracking
+        self.email_hash_name: dict = {}
+        self.email_hash_address: dict = {}
+        self.deduplicate_name_hash: dict = {}
+        self.deduplicate_address_hash: dict = {}
+
+    def _find_root_path(self) -> Path:
+        """Find the root path of the project."""
+        cwd = Path.cwd()
+
+        # Check for MAINTAINERS file in current directory or parents
+        for parent in [cwd] + list(cwd.parents):
+            if (parent / "MAINTAINERS").exists():
+                return parent
+            # Also check for common project indicators
+            if (parent / ".git").exists() or (parent / ".hg").exists():
+                if (parent / "MAINTAINERS").exists():
+                    return parent
+
+        return cwd
+
+    def _detect_vcs(self) -> Optional[str]:
+        """Detect if git is available."""
+        if self.vcs_type is not None:
+            return self.vcs_type
+
+        # Check for git
+        if (self.root_path / ".git").exists():
+            try:
+                subprocess.run(
+                    ["git", "--version"],
+                    capture_output=True,
+                    check=True
+                )
+                self.vcs_type = "git"
+                return "git"
+            except (subprocess.CalledProcessError, FileNotFoundError):
+                pass
+
+        self.vcs_type = None
+        return None
+
+    def load_maintainers_file(self, path: Optional[Path] = None) -> None:
+        """Load and parse the MAINTAINERS file."""
+        if path is None:
+            path = self.root_path / "MAINTAINERS"
+
+        if not path.exists():
+            print(f"Error: MAINTAINERS file not found: {path}", file=sys.stderr)
+            sys.exit(1)
+
+        current_section: Optional[Section] = None
+
+        with open(path, "r", encoding="utf-8", errors="replace") as f:
+            for line in f:
+                line = line.rstrip("\n\r")
+
+                # Skip empty lines and comments at the start
+                if not line or line.startswith("#"):
+                    continue
+
+                # Check for section header (line not starting with a type letter)
+                match = re.match(r"^([A-Z]):\s*(.*)$", line)
+                if match:
+                    type_char = match.group(1)
+                    value = match.group(2)
+
+                    if current_section is None:
+                        # Create a default section for entries before any header
+                        current_section = Section(name="THE REST")
+                        self.sections.append(current_section)
+
+                    self._process_section_entry(current_section, type_char, value)
+                elif line and not line[0].isspace():
+                    # New section header
+                    current_section = Section(name=line.strip())
+                    self.sections.append(current_section)
+
+    def _process_section_entry(self, section: Section, type_char: str, value: str) -> None:
+        """Process a single entry in a MAINTAINERS section."""
+        if type_char == "M":
+            section.maintainers.append(value)
+        elif type_char == "R":
+            section.reviewers.append(value)
+        elif type_char == "L":
+            section.mailing_lists.append(value)
+        elif type_char == "S":
+            section.status = value
+        elif type_char == "F":
+            # Convert glob pattern to regex
+            pattern = self._glob_to_regex(value)
+            section.files.append((value, pattern))
+        elif type_char == "X":
+            pattern = self._glob_to_regex(value)
+            section.excludes.append((value, pattern))
+        elif type_char == "N":
+            # Regex pattern for filename matching
+            section.regex_patterns.append(value)
+        elif type_char == "K":
+            section.keywords.append(value)
+        elif type_char == "T":
+            section.scm.append(value)
+        elif type_char == "W":
+            section.web.append(value)
+        elif type_char == "B":
+            section.bug.append(value)
+
+    def _glob_to_regex(self, pattern: str) -> str:
+        """Convert a glob pattern to a regex pattern."""
+        # Escape special regex characters except * and ?
+        result = re.escape(pattern)
+        # Convert glob wildcards to regex
+        result = result.replace(r"\*", ".*")
+        result = result.replace(r"\?", ".")
+        # Handle directory patterns
+        if pattern.endswith("/") or os.path.isdir(pattern):
+            if not result.endswith("/"):
+                result += "/"
+            result += ".*"
+        return f"^{result}"
+
+    def load_mailmap(self) -> None:
+        """Load the .mailmap file for email address mapping."""
+        mailmap_path = self.root_path / ".mailmap"
+        if not mailmap_path.exists():
+            return
+
+        try:
+            with open(mailmap_path, "r", encoding="utf-8", errors="replace") as f:
+                for line in f:
+                    line = re.sub(r"#.*$", "", line).strip()
+                    if not line:
+                        continue
+
+                    # Parse different mailmap formats
+                    # name1 <mail1>
+                    match = re.match(r"^([^<]+)<([^>]+)>$", line)
+                    if match:
+                        name = match.group(1).strip()
+                        address = match.group(2).strip()
+                        self.mailmap["names"][address.lower()] = name
+                        continue
+
+                    # <mail1> <mail2>
+                    match = re.match(r"^<([^>]+)>\s*<([^>]+)>$", line)
+                    if match:
+                        real_addr = match.group(1).strip()
+                        wrong_addr = match.group(2).strip()
+                        self.mailmap["addresses"][wrong_addr.lower()] = real_addr
+                        continue
+
+                    # name1 <mail1> <mail2>
+                    match = re.match(r"^(.+)<([^>]+)>\s*<([^>]+)>$", line)
+                    if match:
+                        name = match.group(1).strip()
+                        real_addr = match.group(2).strip()
+                        wrong_addr = match.group(3).strip()
+                        self.mailmap["names"][wrong_addr.lower()] = name
+                        self.mailmap["addresses"][wrong_addr.lower()] = real_addr
+                        continue
+
+                    # name1 <mail1> name2 <mail2>
+                    match = re.match(r"^(.+)<([^>]+)>\s*(.+)\s*<([^>]+)>$", line)
+                    if match:
+                        real_name = match.group(1).strip()
+                        real_addr = match.group(2).strip()
+                        wrong_addr = match.group(4).strip()
+                        wrong_email = f"{match.group(3).strip()} <{wrong_addr}>"
+                        self.mailmap["names"][wrong_email.lower()] = real_name
+                        self.mailmap["addresses"][wrong_email.lower()] = real_addr
+
+        except IOError as e:
+            print(f"Warning: Could not read .mailmap: {e}", file=sys.stderr)
+
+    def load_ignore_file(self) -> None:
+        """Load the .get_maintainer.ignore file."""
+        for search_path in [".", os.environ.get("HOME", ""), ".scripts"]:
+            ignore_path = Path(search_path) / ".get_maintainer.ignore"
+            if ignore_path.exists():
+                try:
+                    with open(ignore_path, "r", encoding="utf-8") as f:
+                        for line in f:
+                            line = re.sub(r"#.*$", "", line).strip()
+                            if line and self._is_valid_email(line):
+                                self.ignore_emails.append(line.lower())
+                except IOError:
+                    pass
+                break
+
+    def load_config_file(self) -> dict:
+        """Load configuration from .get_maintainer.conf file."""
+        config_args = []
+        for search_path in [".", os.environ.get("HOME", ""), ".scripts"]:
+            conf_path = Path(search_path) / ".get_maintainer.conf"
+            if conf_path.exists():
+                try:
+                    with open(conf_path, "r", encoding="utf-8") as f:
+                        for line in f:
+                            line = re.sub(r"#.*$", "", line).strip()
+                            if line:
+                                config_args.extend(line.split())
+                except IOError:
+                    pass
+                break
+        return config_args
+
+    def _is_valid_email(self, email: str) -> bool:
+        """Basic email validation."""
+        return bool(re.match(r"^[^@]+@[^@]+\.[^@]+$", email))
+
+    def parse_email(self, formatted_email: str) -> tuple[str, str]:
+        """Parse an email address into name and address components."""
+        name = ""
+        address = ""
+
+        # Name <email@domain.com>
+        match = re.match(r"^([^<]+)<(.+@.*)>.*$", formatted_email)
+        if match:
+            name = match.group(1).strip().strip('"')
+            address = match.group(2).strip()
+            return name, address
+
+        # <email@domain.com>
+        match = re.match(r"^\s*<(.+@\S*)>.*$", formatted_email)
+        if match:
+            address = match.group(1).strip()
+            return name, address
+
+        # email@domain.com
+        match = re.match(r"^(.+@\S*).*$", formatted_email)
+        if match:
+            address = match.group(1).strip()
+
+        return name, address
+
+    def format_email(self, name: str, address: str, use_name: bool = True) -> str:
+        """Format name and address into a proper email string."""
+        name = name.strip().strip('"')
+        address = address.strip()
+
+        # Escape special characters in name
+        if name and re.search(r'[^\w\s\-]', name):
+            name = f'"{name}"'
+
+        if use_name and name:
+            return f"{name} <{address}>"
+        return address
+
+    def mailmap_email(self, email: str) -> str:
+        """Apply mailmap transformations to an email address."""
+        name, address = self.parse_email(email)
+        formatted = self.format_email(name, address, True)
+
+        real_name = name
+        real_address = address
+
+        # Check by full email first
+        if formatted.lower() in self.mailmap["names"]:
+            real_name = self.mailmap["names"][formatted.lower()]
+        elif address.lower() in self.mailmap["names"]:
+            real_name = self.mailmap["names"][address.lower()]
+
+        if formatted.lower() in self.mailmap["addresses"]:
+            real_address = self.mailmap["addresses"][formatted.lower()]
+        elif address.lower() in self.mailmap["addresses"]:
+            real_address = self.mailmap["addresses"][address.lower()]
+
+        return self.format_email(real_name, real_address, True)
+
+    def deduplicate_email(self, email: str) -> str:
+        """Deduplicate and normalize an email address."""
+        name, address = self.parse_email(email)
+        email = self.format_email(name, address, True)
+        email = self.mailmap_email(email)
+
+        if not self.config["email_remove_duplicates"]:
+            return email
+
+        name, address = self.parse_email(email)
+
+        if name and name.lower() in self.deduplicate_name_hash:
+            stored = self.deduplicate_name_hash[name.lower()]
+            name, address = stored
+        elif address.lower() in self.deduplicate_address_hash:
+            stored = self.deduplicate_address_hash[address.lower()]
+            name, address = stored
+        else:
+            self.deduplicate_name_hash[name.lower()] = (name, address)
+            self.deduplicate_address_hash[address.lower()] = (name, address)
+
+        return self.format_email(name, address, True)
+
+    def file_matches_pattern(self, filepath: str, pattern: str, regex: str) -> bool:
+        """Check if a file matches a pattern."""
+        try:
+            return bool(re.match(regex, filepath))
+        except re.error:
+            return False
+
+    def find_matching_sections(self, filepath: str) -> list[Section]:
+        """Find all sections that match a given file path."""
+        matching = []
+
+        for section in self.sections:
+            excluded = False
+
+            # Check exclude patterns first
+            for pattern, regex in section.excludes:
+                if self.file_matches_pattern(filepath, pattern, regex):
+                    excluded = True
+                    break
+
+            if excluded:
+                continue
+
+            # Check file patterns
+            for pattern, regex in section.files:
+                if self.file_matches_pattern(filepath, pattern, regex):
+                    matching.append(section)
+                    break
+            else:
+                # Check regex patterns (N: entries)
+                for regex in section.regex_patterns:
+                    try:
+                        if re.search(regex, filepath):
+                            matching.append(section)
+                            break
+                    except re.error:
+                        pass
+
+        return matching
+
+    def get_files_from_patch(self, patch_path: str) -> list[str]:
+        """Extract file paths from a patch file."""
+        files = []
+        fixes = []
+
+        try:
+            with open(patch_path, "r", encoding="utf-8", errors="replace") as f:
+                for line in f:
+                    # diff --git a/file1 b/file2
+                    match = re.match(r"^diff --git a/(\S+) b/(\S+)\s*$", line)
+                    if match:
+                        files.append(match.group(1))
+                        files.append(match.group(2))
+                        continue
+
+                    # +++ b/file or --- a/file
+                    match = re.match(r"^(?:\+\+\+|---)\s+[ab]/(.+)$", line)
+                    if match:
+                        files.append(match.group(1))
+                        continue
+
+                    # mode change
+                    match = re.match(r"^ mode change [0-7]+ => [0-7]+ (\S+)\s*$", line)
+                    if match:
+                        files.append(match.group(1))
+                        continue
+
+                    # rename from/to
+                    match = re.match(r"^rename (?:from|to) (\S+)\s*$", line)
+                    if match:
+                        files.append(match.group(1))
+                        continue
+
+                    # Fixes: tag
+                    if self.config["email_fixes"]:
+                        match = re.match(r"^Fixes:\s+([0-9a-fA-F]{6,40})", line)
+                        if match:
+                            fixes.append(match.group(1))
+
+        except IOError as e:
+            print(f"Error reading patch file: {e}", file=sys.stderr)
+            return []
+
+        # Remove duplicates while preserving order
+        seen = set()
+        unique_files = []
+        for f in files:
+            if f not in seen:
+                seen.add(f)
+                unique_files.append(f)
+
+        return unique_files
+
+    def add_email(self, email: str, role: str) -> None:
+        """Add an email address to the results."""
+        name, address = self.parse_email(email)
+
+        if not address:
+            return
+
+        if address.lower() in [e.lower() for e in self.ignore_emails]:
+            return
+
+        formatted = self.format_email(name, address, self.config["email_usename"])
+
+        # Check for duplicates
+        if self.config["email_remove_duplicates"]:
+            if name and name.lower() in self.email_hash_name:
+                # Update role if needed
+                for entry in self.email_to:
+                    entry_name, _ = self.parse_email(entry.email)
+                    if entry_name.lower() == name.lower():
+                        if role and role not in entry.role:
+                            if entry.role:
+                                entry.role += f",{role}"
+                            else:
+                                entry.role = role
+                        return
+            if address.lower() in self.email_hash_address:
+                for entry in self.email_to:
+                    _, entry_addr = self.parse_email(entry.email)
+                    if entry_addr.lower() == address.lower():
+                        if role and role not in entry.role:
+                            if entry.role:
+                                entry.role += f",{role}"
+                            else:
+                                entry.role = role
+                        return
+
+        entry = MaintainerEntry(email=formatted, role=role)
+        self.email_to.append(entry)
+
+        if name:
+            self.email_hash_name[name.lower()] = True
+        self.email_hash_address[address.lower()] = True
+
+    def add_list(self, list_addr: str, role: str) -> None:
+        """Add a mailing list to the results."""
+        # Parse list address and any additional info
+        parts = list_addr.split(None, 1)
+        address = parts[0]
+        additional = parts[1] if len(parts) > 1 else ""
+
+        # Check for subscribers-only or moderated lists
+        if "subscribers-only" in additional:
+            if not self.config["email_subscriber_list"]:
+                return
+            role = f"subscriber list:{role}" if role else "subscriber list"
+        elif "moderated" in additional:
+            if not self.config["email_moderated_list"]:
+                return
+            role = f"moderated list:{role}" if role else "moderated list"
+        else:
+            role = f"open list:{role}" if role else "open list"
+
+        # Check for duplicates
+        for entry in self.list_to:
+            if entry.email.lower() == address.lower():
+                return
+
+        self.list_to.append(MaintainerEntry(email=address, role=role))
+
+    def process_section(self, section: Section, suffix: str = "") -> None:
+        """Process a matching section and add its entries."""
+        subsystem_name = section.name
+        if (self.config["output_section_maxlen"] and
+                len(subsystem_name) > self.config["output_section_maxlen"]):
+            subsystem_name = subsystem_name[:self.config["output_section_maxlen"] - 3] + "..."
+
+        # Add maintainers
+        if self.config["email_maintainer"]:
+            for maintainer in section.maintainers:
+                role = f"maintainer:{subsystem_name}{suffix}"
+                self.add_email(maintainer, role)
+
+        # Add reviewers
+        if self.config["email_reviewer"]:
+            for reviewer in section.reviewers:
+                role = f"reviewer:{subsystem_name}{suffix}"
+                self.add_email(reviewer, role)
+
+        # Add mailing lists
+        if self.config["email_list"]:
+            for mailing_list in section.mailing_lists:
+                role = subsystem_name if subsystem_name != "THE REST" else ""
+                self.add_list(mailing_list, role + suffix)
+
+        # Add SCM info
+        if self.config["scm"]:
+            for scm in section.scm:
+                self.scm_list.append(scm + suffix)
+
+        # Add web info
+        if self.config["web"]:
+            for web in section.web:
+                self.web_list.append(web + suffix)
+
+        # Add bug info
+        if self.config["bug"]:
+            for bug in section.bug:
+                self.bug_list.append(bug + suffix)
+
+        # Add subsystem
+        if self.config["subsystem"]:
+            self.subsystem_list.append(section.name + suffix)
+
+        # Add status
+        if self.config["status"] and section.status:
+            self.status_list.append(section.status + suffix)
+
+    def get_git_signers(self, filepath: str) -> list[tuple[str, int]]:
+        """Get commit signers from git history for a file."""
+        if self._detect_vcs() != "git":
+            return []
+
+        cmd = [
+            "git", "log",
+            "--no-color", "--follow",
+            f"--since={self.config['email_git_since']}",
+            "--numstat", "--no-merges",
+            '--format=GitCommit: %H%nGitAuthor: %an <%ae>%nGitDate: %aD%nGitSubject: %s%n%b',
+            "--", filepath
+        ]
+
+        try:
+            result = subprocess.run(
+                cmd,
+                capture_output=True,
+                text=True,
+                cwd=self.root_path
+            )
+            if result.returncode != 0:
+                return []
+
+            signers = defaultdict(int)
+            signature_pattern = "|".join(re.escape(tag) for tag in SIGNATURE_TAGS)
+            if self.config["email_git_all_signature_types"]:
+                signature_pattern = r".+[Bb][Yy]:"
+
+            for line in result.stdout.split("\n"):
+                # Match author lines
+                match = re.match(r"^GitAuthor:\s*(.+)$", line)
+                if match:
+                    email = self.deduplicate_email(match.group(1))
+                    signers[email] += 1
+                    continue
+
+                # Match signature lines
+                match = re.match(rf"^\s*({signature_pattern})\s*(.+@.+)$", line)
+                if match:
+                    email = self.deduplicate_email(match.group(2))
+                    signers[email] += 1
+
+            return sorted(signers.items(), key=lambda x: -x[1])
+
+        except (subprocess.CalledProcessError, FileNotFoundError):
+            return []
+
+    def add_vcs_signers(self, filepath: str, exact_match: bool) -> None:
+        """Add signers from git history."""
+        if not self.config["email_git"]:
+            if not (self.config["email_git_fallback"] and not exact_match):
+                return
+
+        if self._detect_vcs() != "git":
+            return
+
+        signers = self.get_git_signers(filepath)
+
+        total_commits = sum(count for _, count in signers)
+        if total_commits == 0:
+            return
+
+        added = 0
+        for email, count in signers:
+            if added >= self.config["email_git_max_maintainers"]:
+                break
+            if count < self.config["email_git_min_signatures"]:
+                break
+
+            percent = (count * 100) // total_commits
+            if percent < self.config["email_git_min_percent"]:
+                break
+
+            if self.config["output_rolestats"]:
+                role = f"commit_signer:{count}/{total_commits}={percent}%"
+            else:
+                role = "commit_signer"
+
+            self.add_email(email, role)
+            added += 1
+
+    def find_maintainers(self, files: list[str]) -> None:
+        """Find maintainers for the given files."""
+        exact_matches = set()
+
+        for filepath in files:
+            matching_sections = self.find_matching_sections(filepath)
+
+            # Track if we found an exact match
+            for section in matching_sections:
+                if section.status and "maintain" in section.status.lower():
+                    if section.maintainers:
+                        exact_matches.add(filepath)
+
+            for section in matching_sections:
+                self.process_section(section)
+
+        # Add VCS signers
+        if self.config["email"]:
+            for filepath in files:
+                exact_match = filepath in exact_matches
+                self.add_vcs_signers(filepath, exact_match)
+
+    def output_results(self) -> None:
+        """Output the results."""
+        results = []
+
+        # Combine and deduplicate results
+        seen_emails = set()
+
+        if self.config["email"]:
+            for entry in self.email_to + self.list_to:
+                email_lower = entry.email.lower()
+                if email_lower in seen_emails:
+                    continue
+                seen_emails.add(email_lower)
+
+                if self.config["output_roles"] or self.config["output_rolestats"]:
+                    results.append(f"{entry.email} ({entry.role})")
+                else:
+                    results.append(entry.email)
+
+        # Output
+        if self.config["output_multiline"]:
+            for result in results:
+                print(result)
+        else:
+            print(self.config["output_separator"].join(results))
+
+        # Additional outputs
+        if self.config["scm"]:
+            for scm in sorted(set(self.scm_list)):
+                print(scm)
+
+        if self.config["status"]:
+            for status in sorted(set(self.status_list)):
+                print(status)
+
+        if self.config["subsystem"]:
+            for subsystem in sorted(set(self.subsystem_list)):
+                print(subsystem)
+
+        if self.config["web"]:
+            for web in sorted(set(self.web_list)):
+                print(web)
+
+        if self.config["bug"]:
+            for bug in sorted(set(self.bug_list)):
+                print(bug)
+
+
+def parse_args() -> argparse.Namespace:
+    """Parse command line arguments."""
+    parser = argparse.ArgumentParser(
+        description="Find maintainers and mailing lists for patches or files",
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+        epilog="""
+Examples:
+  %(prog)s patch.diff              Find maintainers for a patch
+  %(prog)s -f drivers/net/foo.c    Find maintainers for a file
+  %(prog)s --no-git patch.diff     Skip git history analysis
+
+Default options:
+  [--email --nogit --git-fallback --m --r --n --l --multiline
+   --pattern-depth=0 --remove-duplicates --rolestats --keywords]
+"""
+    )
+
+    parser.add_argument("files", nargs="*", help="Patch files or files to check")
+    parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {VERSION}")
+
+    # Email options
+    email_group = parser.add_argument_group("Email options")
+    email_group.add_argument("--email", dest="email", action="store_true", default=True,
+                            help="Print email addresses (default)")
+    email_group.add_argument("--no-email", dest="email", action="store_false",
+                            help="Don't print email addresses")
+    email_group.add_argument("-m", dest="email_maintainer", action="store_true", default=True,
+                            help="Include maintainers")
+    email_group.add_argument("--no-m", dest="email_maintainer", action="store_false",
+                            help="Exclude maintainers")
+    email_group.add_argument("-r", dest="email_reviewer", action="store_true", default=True,
+                            help="Include reviewers")
+    email_group.add_argument("--no-r", dest="email_reviewer", action="store_false",
+                            help="Exclude reviewers")
+    email_group.add_argument("-n", dest="email_usename", action="store_true", default=True,
+                            help="Include name in email")
+    email_group.add_argument("--no-n", dest="email_usename", action="store_false",
+                            help="Don't include name in email")
+    email_group.add_argument("-l", dest="email_list", action="store_true", default=True,
+                            help="Include mailing lists")
+    email_group.add_argument("--no-l", dest="email_list", action="store_false",
+                            help="Exclude mailing lists")
+    email_group.add_argument("--moderated", dest="email_moderated_list", action="store_true", default=True,
+                            help="Include moderated mailing lists")
+    email_group.add_argument("--no-moderated", dest="email_moderated_list", action="store_false",
+                            help="Exclude moderated mailing lists")
+    email_group.add_argument("-s", dest="email_subscriber_list", action="store_true", default=False,
+                            help="Include subscriber-only mailing lists")
+    email_group.add_argument("--no-s", dest="email_subscriber_list", action="store_false",
+                            help="Exclude subscriber-only mailing lists")
+    email_group.add_argument("--remove-duplicates", dest="email_remove_duplicates",
+                            action="store_true", default=True,
+                            help="Remove duplicate email addresses")
+    email_group.add_argument("--no-remove-duplicates", dest="email_remove_duplicates",
+                            action="store_false",
+                            help="Don't remove duplicate email addresses")
+    email_group.add_argument("--mailmap", dest="email_use_mailmap", action="store_true", default=True,
+                            help="Use .mailmap file")
+    email_group.add_argument("--no-mailmap", dest="email_use_mailmap", action="store_false",
+                            help="Don't use .mailmap file")
+    email_group.add_argument("--fixes", dest="email_fixes", action="store_true", default=True,
+                            help="Add signers from Fixes: commits")
+    email_group.add_argument("--no-fixes", dest="email_fixes", action="store_false",
+                            help="Don't add signers from Fixes: commits")
+
+    # Git options
+    git_group = parser.add_argument_group("Git options")
+    git_group.add_argument("--git", dest="email_git", action="store_true", default=False,
+                          help="Include recent git signers")
+    git_group.add_argument("--no-git", dest="email_git", action="store_false",
+                          help="Don't include git signers")
+    git_group.add_argument("--git-fallback", dest="email_git_fallback", action="store_true", default=True,
+                          help="Use git when no exact MAINTAINERS match")
+    git_group.add_argument("--no-git-fallback", dest="email_git_fallback", action="store_false",
+                          help="Don't use git fallback")
+    git_group.add_argument("--git-all-signature-types", dest="email_git_all_signature_types",
+                          action="store_true", default=False,
+                          help="Include all signature types")
+    git_group.add_argument("--git-blame", dest="email_git_blame", action="store_true", default=False,
+                          help="Use git blame")
+    git_group.add_argument("--no-git-blame", dest="email_git_blame", action="store_false",
+                          help="Don't use git blame")
+    git_group.add_argument("--git-min-signatures", type=int, default=1,
+                          help="Minimum signatures required (default: 1)")
+    git_group.add_argument("--git-max-maintainers", type=int, default=5,
+                          help="Maximum maintainers to add (default: 5)")
+    git_group.add_argument("--git-min-percent", type=int, default=5,
+                          help="Minimum percentage of commits (default: 5)")
+    git_group.add_argument("--git-since", default="1-year-ago",
+                          help="Git history to use (default: 1-year-ago)")
+
+    # Output options
+    output_group = parser.add_argument_group("Output options")
+    output_group.add_argument("--multiline", dest="output_multiline", action="store_true", default=True,
+                             help="Print one entry per line (default)")
+    output_group.add_argument("--no-multiline", dest="output_multiline", action="store_false",
+                             help="Print all entries on one line")
+    output_group.add_argument("--separator", dest="output_separator", default=", ",
+                             help="Separator for single-line output (default: ', ')")
+    output_group.add_argument("--roles", dest="output_roles", action="store_true", default=False,
+                             help="Show roles")
+    output_group.add_argument("--no-roles", dest="output_roles", action="store_false",
+                             help="Don't show roles")
+    output_group.add_argument("--rolestats", dest="output_rolestats", action="store_true", default=True,
+                             help="Show roles and statistics (default)")
+    output_group.add_argument("--no-rolestats", dest="output_rolestats", action="store_false",
+                             help="Don't show role statistics")
+
+    # Other options
+    other_group = parser.add_argument_group("Other options")
+    other_group.add_argument("-f", "--file", dest="from_filename", action="store_true", default=False,
+                            help="Treat arguments as filenames, not patches")
+    other_group.add_argument("--scm", action="store_true", default=False,
+                            help="Print SCM information")
+    other_group.add_argument("--no-scm", dest="scm", action="store_false",
+                            help="Don't print SCM information")
+    other_group.add_argument("--status", action="store_true", default=False,
+                            help="Print status information")
+    other_group.add_argument("--no-status", dest="status", action="store_false",
+                            help="Don't print status information")
+    other_group.add_argument("--subsystem", action="store_true", default=False,
+                            help="Print subsystem name")
+    other_group.add_argument("--no-subsystem", dest="subsystem", action="store_false",
+                            help="Don't print subsystem name")
+    other_group.add_argument("--web", action="store_true", default=False,
+                            help="Print website information")
+    other_group.add_argument("--no-web", dest="web", action="store_false",
+                            help="Don't print website information")
+    other_group.add_argument("--bug", action="store_true", default=False,
+                            help="Print bug reporting information")
+    other_group.add_argument("--no-bug", dest="bug", action="store_false",
+                            help="Don't print bug reporting information")
+    other_group.add_argument("-k", "--keywords", action="store_true", default=True,
+                            help="Scan for keywords")
+    other_group.add_argument("--no-keywords", dest="keywords", action="store_false",
+                            help="Don't scan for keywords")
+    other_group.add_argument("--pattern-depth", type=int, default=0,
+                            help="Pattern directory traversal depth (default: 0 = all)")
+    other_group.add_argument("--sections", action="store_true", default=False,
+                            help="Print all matching sections")
+    other_group.add_argument("--maintainer-path", "--mpath",
+                            help="Path to MAINTAINERS file")
+
+    return parser.parse_args()
+
+
+def main():
+    """Main entry point."""
+    args = parse_args()
+
+    # Build configuration from arguments
+    config = DEFAULT_CONFIG.copy()
+    for key in config:
+        if hasattr(args, key):
+            config[key] = getattr(args, key)
+
+    # Handle special cases
+    if args.output_separator != ", ":
+        config["output_multiline"] = False
+
+    if config["output_rolestats"]:
+        config["output_roles"] = True
+
+    # Create maintainer finder
+    gm = GetMaintainer(config)
+
+    # Load configuration files
+    gm.load_ignore_file()
+    if config["email_use_mailmap"]:
+        gm.load_mailmap()
+
+    # Load MAINTAINERS file
+    if args.maintainer_path:
+        gm.load_maintainers_file(Path(args.maintainer_path))
+    else:
+        gm.load_maintainers_file()
+
+    # Get files to process
+    if not args.files:
+        if sys.stdin.isatty():
+            print("Error: No files specified", file=sys.stderr)
+            sys.exit(1)
+        # Read from stdin
+        args.files = ["-"]
+
+    all_files = []
+    for file_arg in args.files:
+        if file_arg == "-":
+            # Read patch from stdin
+            import tempfile
+            with tempfile.NamedTemporaryFile(mode="w", suffix=".patch", delete=False) as tmp:
+                tmp.write(sys.stdin.read())
+                tmp_path = tmp.name
+            all_files.extend(gm.get_files_from_patch(tmp_path))
+            os.unlink(tmp_path)
+        elif args.from_filename:
+            # Treat as file path
+            all_files.append(file_arg)
+        else:
+            # Treat as patch file
+            patch_files = gm.get_files_from_patch(file_arg)
+            if not patch_files:
+                print(f"Warning: '{file_arg}' doesn't appear to be a patch. Use -f to treat as file.",
+                      file=sys.stderr)
+            all_files.extend(patch_files)
+
+    if not all_files:
+        print("Error: No files found to process", file=sys.stderr)
+        sys.exit(1)
+
+    # Find maintainers
+    gm.find_maintainers(all_files)
+
+    # Output results
+    gm.output_results()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/devtools/get-maintainer.sh b/devtools/get-maintainer.sh
index bba4d3f68d..915c31a359 100755
--- a/devtools/get-maintainer.sh
+++ b/devtools/get-maintainer.sh
@@ -1,34 +1,9 @@
 #!/bin/sh
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright(c) 2017 Intel Corporation
+#
+# Wrapper script for get_maintainer.py for backward compatibility
 
+SCRIPT_DIR=$(dirname $(readlink -f $0))
 
-# Load config options:
-# - DPDK_GETMAINTAINER_PATH
-. $(dirname $(readlink -f $0))/load-devel-config
-
-options="--no-tree --no-git-fallback"
-options="$options --no-rolestats"
-
-print_usage () {
-	cat <<- END_OF_HELP
-	usage: $(basename $0) <patch>
-
-	The DPDK_GETMAINTAINER_PATH variable should be set to the full path to
-	the get_maintainer.pl script located in Linux kernel sources. Example:
-	DPDK_GETMAINTAINER_PATH=~/linux/scripts/get_maintainer.pl
-
-	Also refer to devtools/load-devel-config to store your configuration.
-	END_OF_HELP
-}
-
-# Requires DPDK_GETMAINTAINER_PATH devel config option set
-if [ ! -f "$DPDK_GETMAINTAINER_PATH" ] ||
-   [ ! -x "$DPDK_GETMAINTAINER_PATH" ] ; then
-	print_usage >&2
-	echo
-	echo 'Cannot execute DPDK_GETMAINTAINER_PATH' >&2
-	exit 1
-fi
-
-$DPDK_GETMAINTAINER_PATH $options $@
+exec python3 "$SCRIPT_DIR/get-maintainer.py" "$@"
diff --git a/doc/guides/contributing/patches.rst b/doc/guides/contributing/patches.rst
index 069a18e4ec..c46fca8eb9 100644
--- a/doc/guides/contributing/patches.rst
+++ b/doc/guides/contributing/patches.rst
@@ -562,9 +562,9 @@ The appropriate maintainer can be found in the ``MAINTAINERS`` file::
 
    git send-email --to maintainer@some.org --cc dev@dpdk.org 000*.patch
 
-Script ``get-maintainer.sh`` can be used to select maintainers automatically::
+Script ``get-maintainer.py`` can be used to select maintainers automatically::
 
-  git send-email --to-cmd ./devtools/get-maintainer.sh --cc dev@dpdk.org 000*.patch
+  git send-email --to-cmd ./devtools/get-maintainer.py --cc dev@dpdk.org 000*.patch
 
 You can test the emails by sending it to yourself or with the ``--dry-run`` option.
 
-- 
2.51.0


^ permalink raw reply related	[flat|nested] 4+ messages in thread

end of thread, other threads:[~2026-09-08  7:36 UTC | newest]

Thread overview: 4+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-01-31 21:56 [RFC] devtools: replace checkpatches shell wrapper with Python script Stephen Hemminger
2026-09-07 16:57 ` [PATCH v5] devtools: add Python-based patch style checker Stephen Hemminger
2026-09-08  7:36   ` Bruce Richardson
  -- strict thread matches above, loose matches on Subject: below --
2026-01-31 20:48 [RFC] devtools: replace get-maintainer shell wrapper with Python script Stephen Hemminger
2026-02-26 17:15 ` [PATCH v5] devtools: add Python-based patch style checker Stephen Hemminger

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.