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 <[email protected]>
---
 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 protected]>
+        match = re.match(r"^([^<]+)<(.+@.*)>.*$", formatted_email)
+        if match:
+            name = match.group(1).strip().strip('"')
+            address = match.group(2).strip()
+            return name, address
+
+        # <[email protected]>
+        match = re.match(r"^\s*<(.+@\S*)>.*$", formatted_email)
+        if match:
+            address = match.group(1).strip()
+            return name, address
+
+        # [email protected]
+        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 [email protected] --cc [email protected] 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 [email protected] 
000*.patch
+  git send-email --to-cmd ./devtools/get-maintainer.py --cc [email protected] 
000*.patch
 
 You can test the emails by sending it to yourself or with the ``--dry-run`` 
option.
 
-- 
2.51.0


Reply via email to