Browse Source
Adding the get_maintainer.py script Signed-off-by: Anas Nashif <anas.nashif@intel.com> Signed-off-by: Ioannis Glaropoulos <Ioannis.Glaropoulos@nordicsemi.no>pull/27062/head
1 changed files with 552 additions and 0 deletions
@ -0,0 +1,552 @@
@@ -0,0 +1,552 @@
|
||||
#!/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 CLoader as Loader |
||||
except ImportError: |
||||
from yaml import Loader |
||||
|
||||
|
||||
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 maintaier.") |
||||
|
||||
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) |
||||
|
||||
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 _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=Loader) |
||||
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", "orphaned", "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() |
Loading…
Reference in new issue