[RFC] devtools: replace get-maintainer shell wrapper with Python script

Stephen Hemminger
2026-01-31T20:48:31Z

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