blob: 9aa11527377a026d1b13ab3ad69bb709b9ed7fb5 [file] [log] [blame]
# Copyright 2020 The Pigweed Authors
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not
# use this file except in compliance with the License. You may obtain a copy of
# the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under
# the License.
#
"""OWNERS file checks."""
import argparse
import collections
import dataclasses
import difflib
import enum
import functools
import logging
import pathlib
import re
import sys
from typing import (
Callable,
Collection,
DefaultDict,
Dict,
Iterable,
List,
OrderedDict,
Set,
Union,
)
from pw_presubmit import git_repo
from pw_presubmit.presubmit import PresubmitFailure
_LOG = logging.getLogger(__name__)
class LineType(enum.Enum):
COMMENT = "comment"
WILDCARD = "wildcard"
FILE_LEVEL = "file_level"
FILE_RULE = "file_rule"
INCLUDE = "include"
PER_FILE = "per-file"
USER = "user"
# Special type to hold lines that don't get attached to another type
TRAILING_COMMENTS = "trailing-comments"
_LINE_TYPERS: OrderedDict[LineType, Callable[
[str], bool]] = collections.OrderedDict((
(LineType.COMMENT, lambda x: x.startswith("#")),
(LineType.WILDCARD, lambda x: x == "*"),
(LineType.FILE_LEVEL, lambda x: x.startswith("set ")),
(LineType.FILE_RULE, lambda x: x.startswith("file:")),
(LineType.INCLUDE, lambda x: x.startswith("include ")),
(LineType.PER_FILE, lambda x: x.startswith("per-file ")),
(LineType.USER,
lambda x: bool(re.match("^[a-zA-Z1-9.+-]+@[a-zA-Z0-9.-]+", x))),
))
class OwnersError(Exception):
"""Generic level OWNERS file error."""
def __init__(self, message: str, *args: object) -> None:
super().__init__(*args)
self.message = message
class FormatterError(OwnersError):
"""Errors where formatter doesn't know how to act."""
class OwnersDuplicateError(OwnersError):
"""Errors where duplicate lines are found in OWNERS files."""
class OwnersUserGrantError(OwnersError):
"""Invalid user grant, * is used with any other grant."""
class OwnersProhibitedError(OwnersError):
"""Any line that is prohibited by the owners syntax.
https://android-review.googlesource.com/plugins/code-owners/Documentation/backend-find-owners.html
"""
class OwnersDependencyError(OwnersError):
"""OWNERS file tried to import file that does not exists."""
class OwnersInvalidLineError(OwnersError):
"""Line in OWNERS file does not match any 'line_typer'."""
class OwnersStyleError(OwnersError):
"""OWNERS file does not match style guide."""
@dataclasses.dataclass
class Line:
content: str
comments: List[str] = dataclasses.field(default_factory=list)
class OwnersFile:
"""Holds OWNERS file in easy to use parsed structure."""
path: pathlib.Path
original_lines: List[str]
sections: Dict[LineType, List[Line]]
formatted_lines: List[str]
def __init__(self, path: pathlib.Path) -> None:
if not path.exists():
error_msg = (f"Tried to import {path} but it does not exists")
raise OwnersDependencyError(error_msg)
self.path = path
self.original_lines = self.load_owners_file(self.path)
cleaned_lines = self.clean_lines(self.original_lines)
self.sections = self.parse_owners(cleaned_lines)
self.formatted_lines = self.format_sections(self.sections)
@staticmethod
def load_owners_file(owners_file: pathlib.Path) -> List[str]:
return owners_file.read_text().split("\n")
@staticmethod
def clean_lines(dirty_lines: List[str]) -> List[str]:
"""Removes extra whitespace from list of strings."""
cleaned_lines = []
for line in dirty_lines:
line = line.strip() # Remove initial and trailing whitespace
# Compress duplicated whitespace and remove tabs.
# Allow comment lines to break this rule as they may have initial
# whitespace for lining up text with neighboring lines.
if not line.startswith("#"):
line = re.sub(r"\s+", " ", line)
if line:
cleaned_lines.append(line)
return cleaned_lines
@staticmethod
def __find_line_type(line: str) -> LineType:
for line_type, type_matcher in _LINE_TYPERS.items():
if type_matcher(line):
return line_type
raise OwnersInvalidLineError(
f"Unrecognized OWNERS file line, '{line}'.")
@staticmethod
def parse_owners(
cleaned_lines: List[str]) -> DefaultDict[LineType, List[Line]]:
"""Converts text lines of OWNERS into structured object."""
sections: DefaultDict[LineType,
List[Line]] = collections.defaultdict(list)
comment_buffer: List[str] = []
def add_line_to_sections(sections, section: LineType, line: str,
comment_buffer: List[str]):
if any(seen_line.content == line
for seen_line in sections[section]):
raise OwnersDuplicateError(f"Duplicate line '{line}'.")
line_obj = Line(content=line, comments=comment_buffer)
sections[section].append(line_obj)
for line in cleaned_lines:
line_type: LineType = OwnersFile.__find_line_type(line)
if line_type == LineType.COMMENT:
comment_buffer.append(line)
else:
add_line_to_sections(sections, line_type, line, comment_buffer)
comment_buffer = []
add_line_to_sections(sections, LineType.TRAILING_COMMENTS, "",
comment_buffer)
return sections
@staticmethod
def format_sections(
sections: DefaultDict[LineType, List[Line]]) -> List[str]:
"""Returns ideally styled OWNERS file.
The styling rules are
* Content will be sorted in the following orders with a blank line
separating
* "set noparent"
* "include" lines
* "file:" lines
* user grants (example, "*", foo@example.com)
* "per-file:" lines
* Do not combine user grants and "*"
* User grants should be sorted alphabetically (this assumes English
ordering)
Returns:
List of strings that make up a styled version of a OWNERS file.
Raises:
FormatterError: When formatter does not handle all lines of input.
This is a coding error in owners_checks.
"""
all_sections = [
LineType.FILE_LEVEL, LineType.INCLUDE, LineType.FILE_RULE,
LineType.WILDCARD, LineType.USER, LineType.PER_FILE,
LineType.TRAILING_COMMENTS
]
formatted_lines: List[str] = []
def append_section(line_type):
# Add a line of separation if there was a previous section and our
# current section has any content. I.e. do not lead with padding and
# do not have multiple successive lines of padding.
if (formatted_lines and line_type != LineType.TRAILING_COMMENTS
and sections[line_type]):
formatted_lines.append("")
sections[line_type].sort(key=lambda line: line.content)
for line in sections[line_type]:
# Strip keep-sorted comments out since sorting is done by this
# script
formatted_lines.extend([
comment for comment in line.comments
if not comment.startswith("# keep-sorted: ")
])
formatted_lines.append(line.content)
for section in all_sections:
append_section(section)
if any(section_name not in all_sections for section_name in sections):
raise FormatterError("Formatter did not process all sections.")
return formatted_lines
def check_style(self) -> None:
"""Checks styling of OWNERS file.
Enforce consistent style on OWNERS file. This also incidentally detects a
few classes of errors.
Raises:
OwnersStyleError: Indicates styled lines do not match original input.
"""
if self.original_lines != self.formatted_lines:
print("\n".join(
difflib.unified_diff(self.original_lines,
self.formatted_lines,
fromfile=str(self.path),
tofile="styled",
lineterm="")))
raise OwnersStyleError(
"OWNERS file format does not follow styling.")
def look_for_owners_errors(self) -> None:
"""Scans owners files for invalid or useless content."""
# Confirm when using the wildcard("*") we don't also try to use
# individual user grants.
if self.sections[LineType.WILDCARD] and self.sections[LineType.USER]:
raise OwnersUserGrantError(
"Do not use '*' with individual user "
"grants, * already applies to all users.")
# NOTE: Using the include keyword in combination with a per-file rule is
# not possible.
# https://android-review.googlesource.com/plugins/code-owners/Documentation/backend-find-owners.html#syntax:~:text=NOTE%3A%20Using%20the%20include%20keyword%20in%20combination%20with%20a%20per%2Dfile%20rule%20is%20not%20possible.
if self.sections[LineType.INCLUDE] and self.sections[
LineType.PER_FILE]:
raise OwnersProhibitedError(
"'include' cannot be used with 'per-file'.")
def __complete_path(self, sub_owners_file_path) -> pathlib.Path:
"""Always return absolute path."""
# Absolute paths start with the git/project root
if sub_owners_file_path.startswith("/"):
root = git_repo.root(self.path)
full_path = root / sub_owners_file_path[1:]
else:
# Relative paths start with owners file dir
full_path = self.path.parent / sub_owners_file_path
return full_path.resolve()
def get_dependencies(self) -> List[pathlib.Path]:
"""Finds owners files this file includes."""
dependencies = []
# All the includes
for include in self.sections.get(LineType.INCLUDE, []):
file_str = include.content[len("include "):]
dependencies.append(self.__complete_path(file_str))
# all file: rules:
for file_rule in self.sections.get(LineType.FILE_RULE, []):
file_str = file_rule.content[len("file:"):]
if ":" in file_str:
_LOG.warning(
"TODO(b/254322931): This check does not yet support "
"<project> or <branch> in a file: rule")
_LOG.warning("It will not check line '%s' found in %s",
file_rule.content, self.path)
dependencies.append(self.__complete_path(file_str))
# all the per-file rule includes
for per_file in self.sections.get(LineType.PER_FILE, []):
file_str = per_file.content[len("per-file "):]
access_grant = file_str[file_str.index("=") + 1:]
if access_grant.startswith("file:"):
dependencies.append(
self.__complete_path(access_grant[len("file:"):]))
return dependencies
def write_formatted(self) -> None:
self.path.write_text("\n".join(self.formatted_lines))
def resolve_owners_tree(root_owners: pathlib.Path) -> List[OwnersFile]:
"""Given a starting OWNERS file return it and all of it's dependencies."""
found = []
todo = collections.deque((root_owners, ))
checked: Set[pathlib.Path] = set()
while todo:
cur_file = todo.popleft()
checked.add(cur_file)
owners_obj = OwnersFile(cur_file)
found.append(owners_obj)
new_dependents = owners_obj.get_dependencies()
for new_dep in new_dependents:
if new_dep not in checked and new_dep not in todo:
todo.append(new_dep)
return found
def _run_owners_checks(owners_obj: OwnersFile) -> None:
owners_obj.look_for_owners_errors()
owners_obj.check_style()
def _format_owners_file(owners_obj: OwnersFile) -> None:
owners_obj.look_for_owners_errors()
if owners_obj.original_lines != owners_obj.formatted_lines:
owners_obj.write_formatted()
def _list_unwrapper(
func, list_or_path: Union[Iterable[pathlib.Path], pathlib.Path]
) -> Dict[pathlib.Path, str]:
"""Decorator that accepts Paths or list of Paths and iterates as needed."""
errors: Dict[pathlib.Path, str] = {}
if isinstance(list_or_path, Iterable):
files = list_or_path
else:
files = (list_or_path, )
all_owners_obj: List[OwnersFile] = []
for file in files:
all_owners_obj.extend(resolve_owners_tree(file))
checked: Set[pathlib.Path] = set()
for current_owners in all_owners_obj:
# Ensure we don't check the same file twice
if current_owners.path in checked:
continue
checked.add(current_owners.path)
try:
func(current_owners)
except OwnersError as err:
errors[current_owners.path] = err.message
_LOG.error("%s: %s", str(current_owners.path.absolute()),
err.message)
return errors
# This generates decorated versions of the functions that can used with both
# formatter (which supplies files one at a time) and presubmits (which supplies
# list of files).
run_owners_checks = functools.partial(_list_unwrapper, _run_owners_checks)
format_owners_file = functools.partial(_list_unwrapper, _format_owners_file)
def presubmit_check(
files: Union[pathlib.Path, Collection[pathlib.Path]]) -> None:
errors = run_owners_checks(files)
if errors:
for file in errors:
_LOG.warning(" pw format --fix %s", file)
_LOG.warning("will automatically fix this.")
raise PresubmitFailure
def main() -> int:
"""Standalone test of styling."""
parser = argparse.ArgumentParser()
parser.add_argument("--style", action="store_true")
parser.add_argument("--owners_file", required=True, type=str)
args = parser.parse_args()
try:
owners_obj = OwnersFile(pathlib.Path(args.owners_file))
owners_obj.look_for_owners_errors()
owners_obj.check_style()
except OwnersError as err:
_LOG.error("%s %s", err, err.message)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())