608 lines
20 KiB
Python
Executable File
608 lines
20 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# Copyright (c) 2019 Nordic Semiconductor ASA
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""
|
|
Lists maintainers for files or commits. Similar in function to
|
|
scripts/get_maintainer.pl from Linux, but geared towards GitHub. The mapping is
|
|
in MAINTAINERS.yml.
|
|
|
|
The comment at the top of MAINTAINERS.yml in Zephyr documents the file format.
|
|
|
|
See the help texts for the various subcommands for more information. They can
|
|
be viewed with e.g.
|
|
|
|
./get_maintainer.py path --help
|
|
|
|
This executable doubles as a Python library. Identifiers not prefixed with '_'
|
|
are part of the library API. The library documentation can be viewed with this
|
|
command:
|
|
|
|
$ pydoc get_maintainer
|
|
"""
|
|
|
|
import argparse
|
|
import operator
|
|
import os
|
|
import pathlib
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
|
|
from yaml import load, YAMLError
|
|
try:
|
|
# Use the speedier C LibYAML parser if available
|
|
from yaml import CSafeLoader as SafeLoader
|
|
except ImportError:
|
|
from yaml import SafeLoader
|
|
|
|
|
|
def _main():
|
|
# Entry point when run as an executable
|
|
|
|
args = _parse_args()
|
|
try:
|
|
args.cmd_fn(Maintainers(args.maintainers), args)
|
|
except (MaintainersError, GitError) as e:
|
|
_serr(e)
|
|
|
|
|
|
def _parse_args():
|
|
# Parses arguments when run as an executable
|
|
|
|
parser = argparse.ArgumentParser(
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
description=__doc__)
|
|
|
|
parser.add_argument(
|
|
"-m", "--maintainers",
|
|
metavar="MAINTAINERS_FILE",
|
|
help="Maintainers file to load. If not specified, MAINTAINERS.yml in "
|
|
"the top-level repository directory is used, and must exist. "
|
|
"Paths in the maintainers file will always be taken as relative "
|
|
"to the top-level directory.")
|
|
|
|
subparsers = parser.add_subparsers(
|
|
help="Available commands (each has a separate --help text)")
|
|
|
|
id_parser = subparsers.add_parser(
|
|
"path",
|
|
help="List area(s) for paths")
|
|
id_parser.add_argument(
|
|
"paths",
|
|
metavar="PATH",
|
|
nargs="*",
|
|
help="Path to list areas for")
|
|
id_parser.set_defaults(cmd_fn=Maintainers._path_cmd)
|
|
|
|
commits_parser = subparsers.add_parser(
|
|
"commits",
|
|
help="List area(s) for commit range")
|
|
commits_parser.add_argument(
|
|
"commits",
|
|
metavar="COMMIT_RANGE",
|
|
nargs="*",
|
|
help="Commit range to list areas for (default: HEAD~..)")
|
|
commits_parser.set_defaults(cmd_fn=Maintainers._commits_cmd)
|
|
|
|
list_parser = subparsers.add_parser(
|
|
"list",
|
|
help="List files in areas")
|
|
list_parser.add_argument(
|
|
"area",
|
|
metavar="AREA",
|
|
nargs="?",
|
|
help="Name of area to list files in. If not specified, all "
|
|
"non-orphaned files are listed (all files that do not appear in "
|
|
"any area).")
|
|
list_parser.set_defaults(cmd_fn=Maintainers._list_cmd)
|
|
|
|
areas_parser = subparsers.add_parser(
|
|
"areas",
|
|
help="List areas and maintainers")
|
|
areas_parser.add_argument(
|
|
"maintainer",
|
|
metavar="MAINTAINER",
|
|
nargs="?",
|
|
help="List all areas maintained by maintainer.")
|
|
|
|
areas_parser.set_defaults(cmd_fn=Maintainers._areas_cmd)
|
|
|
|
orphaned_parser = subparsers.add_parser(
|
|
"orphaned",
|
|
help="List orphaned files (files that do not appear in any area)")
|
|
orphaned_parser.add_argument(
|
|
"path",
|
|
metavar="PATH",
|
|
nargs="?",
|
|
help="Limit to files under PATH")
|
|
orphaned_parser.set_defaults(cmd_fn=Maintainers._orphaned_cmd)
|
|
|
|
count_parser = subparsers.add_parser(
|
|
"count",
|
|
help="Count areas, unique maintainers, and / or unique collaborators")
|
|
count_parser.add_argument(
|
|
"-a",
|
|
"--count-areas",
|
|
action="store_true",
|
|
help="Count the number of areas")
|
|
count_parser.add_argument(
|
|
"-c",
|
|
"--count-collaborators",
|
|
action="store_true",
|
|
help="Count the number of unique collaborators")
|
|
count_parser.add_argument(
|
|
"-n",
|
|
"--count-maintainers",
|
|
action="store_true",
|
|
help="Count the number of unique maintainers")
|
|
count_parser.add_argument(
|
|
"-o",
|
|
"--count-unmaintained",
|
|
action="store_true",
|
|
help="Count the number of unmaintained areas")
|
|
count_parser.set_defaults(cmd_fn=Maintainers._count_cmd)
|
|
|
|
args = parser.parse_args()
|
|
if not hasattr(args, "cmd_fn"):
|
|
# Called without a subcommand
|
|
sys.exit(parser.format_usage().rstrip())
|
|
|
|
return args
|
|
|
|
|
|
class Maintainers:
|
|
"""
|
|
Represents the contents of a maintainers YAML file.
|
|
|
|
These attributes are available:
|
|
|
|
areas:
|
|
A dictionary that maps area names to Area instances, for all areas
|
|
defined in the maintainers file
|
|
|
|
filename:
|
|
The path to the maintainers file
|
|
"""
|
|
def __init__(self, filename=None):
|
|
"""
|
|
Creates a Maintainers instance.
|
|
|
|
filename (default: None):
|
|
Path to the maintainers file to parse. If None, MAINTAINERS.yml in
|
|
the top-level directory of the Git repository is used, and must
|
|
exist.
|
|
"""
|
|
self._toplevel = pathlib.Path(_git("rev-parse", "--show-toplevel"))
|
|
|
|
if filename is None:
|
|
self.filename = self._toplevel / "MAINTAINERS.yml"
|
|
else:
|
|
self.filename = pathlib.Path(filename)
|
|
|
|
self.areas = {}
|
|
for area_name, area_dict in _load_maintainers(self.filename).items():
|
|
area = Area()
|
|
area.name = area_name
|
|
area.status = area_dict.get("status")
|
|
area.maintainers = area_dict.get("maintainers", [])
|
|
area.collaborators = area_dict.get("collaborators", [])
|
|
area.inform = area_dict.get("inform", [])
|
|
area.labels = area_dict.get("labels", [])
|
|
area.description = area_dict.get("description")
|
|
|
|
# area._match_fn(path) tests if the path matches files and/or
|
|
# files-regex
|
|
area._match_fn = \
|
|
_get_match_fn(area_dict.get("files"),
|
|
area_dict.get("files-regex"))
|
|
|
|
# Like area._match_fn(path), but for files-exclude and
|
|
# files-regex-exclude
|
|
area._exclude_match_fn = \
|
|
_get_match_fn(area_dict.get("files-exclude"),
|
|
area_dict.get("files-regex-exclude"))
|
|
|
|
self.areas[area_name] = area
|
|
|
|
def path2areas(self, path):
|
|
"""
|
|
Returns a list of Area instances for the areas that contain 'path',
|
|
taken as relative to the current directory
|
|
"""
|
|
# Make directory paths end in '/' so that foo/bar matches foo/bar/.
|
|
# Skip this check in _contains() itself, because the isdir() makes it
|
|
# twice as slow in cases where it's not needed.
|
|
is_dir = os.path.isdir(path)
|
|
|
|
# Make 'path' relative to the repository root and normalize it.
|
|
# normpath() would remove a trailing '/', so we add it afterwards.
|
|
path = os.path.normpath(os.path.join(
|
|
os.path.relpath(os.getcwd(), self._toplevel),
|
|
path))
|
|
|
|
if is_dir:
|
|
path += "/"
|
|
|
|
return [area for area in self.areas.values()
|
|
if area._contains(path)]
|
|
|
|
def commits2areas(self, commits):
|
|
"""
|
|
Returns a set() of Area instances for the areas that contain files that
|
|
are modified by the commit range in 'commits'. 'commits' could be e.g.
|
|
"HEAD~..", to inspect the tip commit
|
|
"""
|
|
res = set()
|
|
# Final '--' is to make sure 'commits' is interpreted as a commit range
|
|
# rather than a path. That might give better error messages.
|
|
for path in _git("diff", "--name-only", commits, "--").splitlines():
|
|
res.update(self.path2areas(path))
|
|
return res
|
|
|
|
def __repr__(self):
|
|
return "<Maintainers for '{}'>".format(self.filename)
|
|
|
|
#
|
|
# Command-line subcommands
|
|
#
|
|
|
|
def _path_cmd(self, args):
|
|
# 'path' subcommand implementation
|
|
|
|
for path in args.paths:
|
|
if not os.path.exists(path):
|
|
_serr("'{}': no such file or directory".format(path))
|
|
|
|
res = set()
|
|
orphaned = []
|
|
for path in args.paths:
|
|
areas = self.path2areas(path)
|
|
res.update(areas)
|
|
if not areas:
|
|
orphaned.append(path)
|
|
|
|
_print_areas(res)
|
|
if orphaned:
|
|
if res:
|
|
print()
|
|
print("Orphaned paths (not in any area):\n" + "\n".join(orphaned))
|
|
|
|
def _commits_cmd(self, args):
|
|
# 'commits' subcommand implementation
|
|
|
|
commits = args.commits or ("HEAD~..",)
|
|
_print_areas({area for commit_range in commits
|
|
for area in self.commits2areas(commit_range)})
|
|
|
|
def _areas_cmd(self, args):
|
|
# 'areas' subcommand implementation
|
|
for area in self.areas.values():
|
|
if args.maintainer:
|
|
if args.maintainer in area.maintainers:
|
|
print("{:25}\t{}".format(area.name, ",".join(area.maintainers)))
|
|
else:
|
|
print("{:25}\t{}".format(area.name, ",".join(area.maintainers)))
|
|
|
|
def _count_cmd(self, args):
|
|
# 'count' subcommand implementation
|
|
|
|
if not (args.count_areas or args.count_collaborators or args.count_maintainers or args.count_unmaintained):
|
|
# if no specific count is provided, print them all
|
|
args.count_areas = True
|
|
args.count_collaborators = True
|
|
args.count_maintainers = True
|
|
args.count_unmaintained = True
|
|
|
|
unmaintained = 0
|
|
collaborators = set()
|
|
maintainers = set()
|
|
|
|
for area in self.areas.values():
|
|
if area.status == 'maintained':
|
|
maintainers = maintainers.union(set(area.maintainers))
|
|
elif area.status == 'odd fixes':
|
|
unmaintained += 1
|
|
collaborators = collaborators.union(set(area.collaborators))
|
|
|
|
if args.count_areas:
|
|
print('{:14}\t{}'.format('areas:', len(self.areas)))
|
|
if args.count_maintainers:
|
|
print('{:14}\t{}'.format('maintainers:', len(maintainers)))
|
|
if args.count_collaborators:
|
|
print('{:14}\t{}'.format('collaborators:', len(collaborators)))
|
|
if args.count_unmaintained:
|
|
print('{:14}\t{}'.format('unmaintained:', unmaintained))
|
|
|
|
def _list_cmd(self, args):
|
|
# 'list' subcommand implementation
|
|
|
|
if args.area is None:
|
|
# List all files that appear in some area
|
|
for path in _ls_files():
|
|
for area in self.areas.values():
|
|
if area._contains(path):
|
|
print(path)
|
|
break
|
|
else:
|
|
# List all files that appear in the given area
|
|
area = self.areas.get(args.area)
|
|
if area is None:
|
|
_serr("'{}': no such area defined in '{}'"
|
|
.format(args.area, self.filename))
|
|
|
|
for path in _ls_files():
|
|
if area._contains(path):
|
|
print(path)
|
|
|
|
def _orphaned_cmd(self, args):
|
|
# 'orphaned' subcommand implementation
|
|
|
|
if args.path is not None and not os.path.exists(args.path):
|
|
_serr("'{}': no such file or directory".format(args.path))
|
|
|
|
for path in _ls_files(args.path):
|
|
for area in self.areas.values():
|
|
if area._contains(path):
|
|
break
|
|
else:
|
|
print(path) # We get here if we never hit the 'break'
|
|
|
|
|
|
class Area:
|
|
"""
|
|
Represents an entry for an area in MAINTAINERS.yml.
|
|
|
|
These attributes are available:
|
|
|
|
status:
|
|
The status of the area, as a string. None if the area has no 'status'
|
|
key. See MAINTAINERS.yml.
|
|
|
|
maintainers:
|
|
List of maintainers. Empty if the area has no 'maintainers' key.
|
|
|
|
collaborators:
|
|
List of collaborators. Empty if the area has no 'collaborators' key.
|
|
|
|
inform:
|
|
List of people to inform on pull requests. Empty if the area has no
|
|
'inform' key.
|
|
|
|
labels:
|
|
List of GitHub labels for the area. Empty if the area has no 'labels'
|
|
key.
|
|
|
|
description:
|
|
Text from 'description' key, or None if the area has no 'description'
|
|
key
|
|
"""
|
|
def _contains(self, path):
|
|
# Returns True if the area contains 'path', and False otherwise
|
|
|
|
return self._match_fn and self._match_fn(path) and not \
|
|
(self._exclude_match_fn and self._exclude_match_fn(path))
|
|
|
|
def __repr__(self):
|
|
return "<Area {}>".format(self.name)
|
|
|
|
|
|
def _print_areas(areas):
|
|
first = True
|
|
for area in sorted(areas, key=operator.attrgetter("name")):
|
|
if not first:
|
|
print()
|
|
first = False
|
|
|
|
print("""\
|
|
{}
|
|
\tstatus: {}
|
|
\tmaintainers: {}
|
|
\tcollaborators: {}
|
|
\tinform: {}
|
|
\tlabels: {}
|
|
\tdescription: {}""".format(area.name,
|
|
area.status,
|
|
", ".join(area.maintainers),
|
|
", ".join(area.collaborators),
|
|
", ".join(area.inform),
|
|
", ".join(area.labels),
|
|
area.description or ""))
|
|
|
|
|
|
def _get_match_fn(globs, regexes):
|
|
# Constructs a single regex that tests for matches against the globs in
|
|
# 'globs' and the regexes in 'regexes'. Parts are joined with '|' (OR).
|
|
# Returns the search() method of the compiled regex.
|
|
#
|
|
# Returns None if there are neither globs nor regexes, which should be
|
|
# interpreted as no match.
|
|
|
|
if not (globs or regexes):
|
|
return None
|
|
|
|
regex = ""
|
|
|
|
if globs:
|
|
glob_regexes = []
|
|
for glob in globs:
|
|
# Construct a regex equivalent to the glob
|
|
glob_regex = glob.replace(".", "\\.").replace("*", "[^/]*") \
|
|
.replace("?", "[^/]")
|
|
|
|
if not glob.endswith("/"):
|
|
# Require a full match for globs that don't end in /
|
|
glob_regex += "$"
|
|
|
|
glob_regexes.append(glob_regex)
|
|
|
|
# The glob regexes must anchor to the beginning of the path, since we
|
|
# return search(). (?:) is a non-capturing group.
|
|
regex += "^(?:{})".format("|".join(glob_regexes))
|
|
|
|
if regexes:
|
|
if regex:
|
|
regex += "|"
|
|
regex += "|".join(regexes)
|
|
|
|
return re.compile(regex).search
|
|
|
|
|
|
def _load_maintainers(path):
|
|
# Returns the parsed contents of the maintainers file 'filename', also
|
|
# running checks on the contents. The returned format is plain Python
|
|
# dicts/lists/etc., mirroring the structure of the file.
|
|
|
|
with open(path, encoding="utf-8") as f:
|
|
try:
|
|
yaml = load(f, Loader=SafeLoader)
|
|
except YAMLError as e:
|
|
raise MaintainersError("{}: YAML error: {}".format(path, e))
|
|
|
|
_check_maintainers(path, yaml)
|
|
return yaml
|
|
|
|
|
|
def _check_maintainers(maints_path, yaml):
|
|
# Checks the maintainers data in 'yaml', which comes from the maintainers
|
|
# file at maints_path, which is a pathlib.Path instance
|
|
|
|
root = maints_path.parent
|
|
|
|
def ferr(msg):
|
|
_err("{}: {}".format(maints_path, msg)) # Prepend the filename
|
|
|
|
if not isinstance(yaml, dict):
|
|
ferr("empty or malformed YAML (not a dict)")
|
|
|
|
ok_keys = {"status", "maintainers", "collaborators", "inform", "files",
|
|
"files-exclude", "files-regex", "files-regex-exclude",
|
|
"labels", "description"}
|
|
|
|
ok_status = {"maintained", "odd fixes", "unmaintained", "obsolete"}
|
|
ok_status_s = ", ".join('"' + s + '"' for s in ok_status) # For messages
|
|
|
|
for area_name, area_dict in yaml.items():
|
|
if not isinstance(area_dict, dict):
|
|
ferr("malformed entry for area '{}' (not a dict)"
|
|
.format(area_name))
|
|
|
|
for key in area_dict:
|
|
if key not in ok_keys:
|
|
ferr("unknown key '{}' in area '{}'"
|
|
.format(key, area_name))
|
|
|
|
if "status" in area_dict and \
|
|
area_dict["status"] not in ok_status:
|
|
ferr("bad 'status' key on area '{}', should be one of {}"
|
|
.format(area_name, ok_status_s))
|
|
|
|
if not area_dict.keys() & {"files", "files-regex"}:
|
|
ferr("either 'files' or 'files-regex' (or both) must be specified "
|
|
"for area '{}'".format(area_name))
|
|
|
|
for list_name in "maintainers", "collaborators", "inform", "files", \
|
|
"files-regex", "labels":
|
|
if list_name in area_dict:
|
|
lst = area_dict[list_name]
|
|
if not (isinstance(lst, list) and
|
|
all(isinstance(elm, str) for elm in lst)):
|
|
ferr("malformed '{}' value for area '{}' -- should "
|
|
"be a list of strings".format(list_name, area_name))
|
|
|
|
for files_key in "files", "files-exclude":
|
|
if files_key in area_dict:
|
|
for glob_pattern in area_dict[files_key]:
|
|
# This could be changed if it turns out to be too slow,
|
|
# e.g. to only check non-globbing filenames. The tuple() is
|
|
# needed due to pathlib's glob() returning a generator.
|
|
paths = tuple(root.glob(glob_pattern))
|
|
if not paths:
|
|
ferr("glob pattern '{}' in '{}' in area '{}' does not "
|
|
"match any files".format(glob_pattern, files_key,
|
|
area_name))
|
|
if not glob_pattern.endswith("/"):
|
|
for path in paths:
|
|
if path.is_dir():
|
|
ferr("glob pattern '{}' in '{}' in area '{}' "
|
|
"matches a directory, but has no "
|
|
"trailing '/'"
|
|
.format(glob_pattern, files_key,
|
|
area_name))
|
|
|
|
for files_regex_key in "files-regex", "files-regex-exclude":
|
|
if files_regex_key in area_dict:
|
|
for regex in area_dict[files_regex_key]:
|
|
try:
|
|
re.compile(regex)
|
|
except re.error as e:
|
|
ferr("bad regular expression '{}' in '{}' in "
|
|
"'{}': {}".format(regex, files_regex_key,
|
|
area_name, e.msg))
|
|
|
|
if "description" in area_dict and \
|
|
not isinstance(area_dict["description"], str):
|
|
ferr("malformed 'description' value for area '{}' -- should be a "
|
|
"string".format(area_name))
|
|
|
|
|
|
def _git(*args):
|
|
# Helper for running a Git command. Returns the rstrip()ed stdout output.
|
|
# Called like git("diff"). Exits with SystemError (raised by sys.exit()) on
|
|
# errors.
|
|
|
|
git_cmd = ("git",) + args
|
|
git_cmd_s = " ".join(shlex.quote(word) for word in git_cmd) # For errors
|
|
|
|
try:
|
|
git_process = subprocess.Popen(
|
|
git_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
except FileNotFoundError:
|
|
_giterr("git executable not found (when running '{}'). Check that "
|
|
"it's in listed in the PATH environment variable"
|
|
.format(git_cmd_s))
|
|
except OSError as e:
|
|
_giterr("error running '{}': {}".format(git_cmd_s, e))
|
|
|
|
stdout, stderr = git_process.communicate()
|
|
if git_process.returncode:
|
|
_giterr("error running '{}'\n\nstdout:\n{}\nstderr:\n{}".format(
|
|
git_cmd_s, stdout.decode("utf-8"), stderr.decode("utf-8")))
|
|
|
|
return stdout.decode("utf-8").rstrip()
|
|
|
|
|
|
def _ls_files(path=None):
|
|
cmd = ["ls-files"]
|
|
if path is not None:
|
|
cmd.append(path)
|
|
return _git(*cmd).splitlines()
|
|
|
|
|
|
def _err(msg):
|
|
raise MaintainersError(msg)
|
|
|
|
|
|
def _giterr(msg):
|
|
raise GitError(msg)
|
|
|
|
|
|
def _serr(msg):
|
|
# For reporting errors when get_maintainer.py is run as a script.
|
|
# sys.exit() shouldn't be used otherwise.
|
|
sys.exit("{}: error: {}".format(sys.argv[0], msg))
|
|
|
|
|
|
class MaintainersError(Exception):
|
|
"Exception raised for MAINTAINERS.yml-related errors"
|
|
|
|
|
|
class GitError(Exception):
|
|
"Exception raised for Git-related errors"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_main()
|