1# Copyright 2020 The Pigweed Authors 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); you may not 4# use this file except in compliance with the License. You may obtain a copy of 5# the License at 6# 7# https://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 12# License for the specific language governing permissions and limitations under 13# the License. 14# 15"""OWNERS file checks.""" 16 17import argparse 18import collections 19import dataclasses 20import difflib 21import enum 22import functools 23import logging 24import pathlib 25import re 26import sys 27from typing import ( 28 Callable, 29 Collection, 30 DefaultDict, 31 Dict, 32 Iterable, 33 List, 34 OrderedDict, 35 Set, 36 Union, 37) 38from pw_presubmit import git_repo 39from pw_presubmit.presubmit import PresubmitFailure 40 41_LOG = logging.getLogger(__name__) 42 43 44class LineType(enum.Enum): 45 COMMENT = "comment" 46 WILDCARD = "wildcard" 47 FILE_LEVEL = "file_level" 48 FILE_RULE = "file_rule" 49 INCLUDE = "include" 50 PER_FILE = "per-file" 51 USER = "user" 52 # Special type to hold lines that don't get attached to another type 53 TRAILING_COMMENTS = "trailing-comments" 54 55 56_LINE_TYPERS: OrderedDict[ 57 LineType, Callable[[str], bool] 58] = collections.OrderedDict( 59 ( 60 (LineType.COMMENT, lambda x: x.startswith("#")), 61 (LineType.WILDCARD, lambda x: x == "*"), 62 (LineType.FILE_LEVEL, lambda x: x.startswith("set ")), 63 (LineType.FILE_RULE, lambda x: x.startswith("file:")), 64 (LineType.INCLUDE, lambda x: x.startswith("include ")), 65 (LineType.PER_FILE, lambda x: x.startswith("per-file ")), 66 ( 67 LineType.USER, 68 lambda x: bool(re.match("^[a-zA-Z1-9.+-]+@[a-zA-Z0-9.-]+", x)), 69 ), 70 ) 71) 72 73 74class OwnersError(Exception): 75 """Generic level OWNERS file error.""" 76 77 def __init__(self, message: str, *args: object) -> None: 78 super().__init__(*args) 79 self.message = message 80 81 82class FormatterError(OwnersError): 83 """Errors where formatter doesn't know how to act.""" 84 85 86class OwnersDuplicateError(OwnersError): 87 """Errors where duplicate lines are found in OWNERS files.""" 88 89 90class OwnersUserGrantError(OwnersError): 91 """Invalid user grant, * is used with any other grant.""" 92 93 94class OwnersProhibitedError(OwnersError): 95 """Any line that is prohibited by the owners syntax. 96 97 https://android-review.googlesource.com/plugins/code-owners/Documentation/backend-find-owners.html 98 """ 99 100 101class OwnersDependencyError(OwnersError): 102 """OWNERS file tried to import file that does not exists.""" 103 104 105class OwnersInvalidLineError(OwnersError): 106 """Line in OWNERS file does not match any 'line_typer'.""" 107 108 109class OwnersStyleError(OwnersError): 110 """OWNERS file does not match style guide.""" 111 112 113@dataclasses.dataclass 114class Line: 115 content: str 116 comments: List[str] = dataclasses.field(default_factory=list) 117 118 119class OwnersFile: 120 """Holds OWNERS file in easy to use parsed structure.""" 121 122 path: pathlib.Path 123 original_lines: List[str] 124 sections: Dict[LineType, List[Line]] 125 formatted_lines: List[str] 126 127 def __init__(self, path: pathlib.Path) -> None: 128 if not path.exists(): 129 error_msg = f"Tried to import {path} but it does not exists" 130 raise OwnersDependencyError(error_msg) 131 self.path = path 132 133 self.original_lines = self.load_owners_file(self.path) 134 cleaned_lines = self.clean_lines(self.original_lines) 135 self.sections = self.parse_owners(cleaned_lines) 136 self.formatted_lines = self.format_sections(self.sections) 137 138 @staticmethod 139 def load_owners_file(owners_file: pathlib.Path) -> List[str]: 140 return owners_file.read_text().split("\n") 141 142 @staticmethod 143 def clean_lines(dirty_lines: List[str]) -> List[str]: 144 """Removes extra whitespace from list of strings.""" 145 146 cleaned_lines = [] 147 for line in dirty_lines: 148 line = line.strip() # Remove initial and trailing whitespace 149 150 # Compress duplicated whitespace and remove tabs. 151 # Allow comment lines to break this rule as they may have initial 152 # whitespace for lining up text with neighboring lines. 153 if not line.startswith("#"): 154 line = re.sub(r"\s+", " ", line) 155 if line: 156 cleaned_lines.append(line) 157 return cleaned_lines 158 159 @staticmethod 160 def __find_line_type(line: str) -> LineType: 161 for line_type, type_matcher in _LINE_TYPERS.items(): 162 if type_matcher(line): 163 return line_type 164 165 raise OwnersInvalidLineError( 166 f"Unrecognized OWNERS file line, '{line}'." 167 ) 168 169 @staticmethod 170 def parse_owners( 171 cleaned_lines: List[str], 172 ) -> DefaultDict[LineType, List[Line]]: 173 """Converts text lines of OWNERS into structured object.""" 174 sections: DefaultDict[LineType, List[Line]] = collections.defaultdict( 175 list 176 ) 177 comment_buffer: List[str] = [] 178 179 def add_line_to_sections( 180 sections, section: LineType, line: str, comment_buffer: List[str] 181 ): 182 if any( 183 seen_line.content == line for seen_line in sections[section] 184 ): 185 raise OwnersDuplicateError(f"Duplicate line '{line}'.") 186 line_obj = Line(content=line, comments=comment_buffer) 187 sections[section].append(line_obj) 188 189 for line in cleaned_lines: 190 line_type: LineType = OwnersFile.__find_line_type(line) 191 if line_type == LineType.COMMENT: 192 comment_buffer.append(line) 193 else: 194 add_line_to_sections(sections, line_type, line, comment_buffer) 195 comment_buffer = [] 196 197 add_line_to_sections( 198 sections, LineType.TRAILING_COMMENTS, "", comment_buffer 199 ) 200 201 return sections 202 203 @staticmethod 204 def format_sections( 205 sections: DefaultDict[LineType, List[Line]] 206 ) -> List[str]: 207 """Returns ideally styled OWNERS file. 208 209 The styling rules are 210 * Content will be sorted in the following orders with a blank line 211 separating 212 * "set noparent" 213 * "include" lines 214 * "file:" lines 215 * user grants (example, "*", foo@example.com) 216 * "per-file:" lines 217 * Do not combine user grants and "*" 218 * User grants should be sorted alphabetically (this assumes English 219 ordering) 220 221 Returns: 222 List of strings that make up a styled version of a OWNERS file. 223 224 Raises: 225 FormatterError: When formatter does not handle all lines of input. 226 This is a coding error in owners_checks. 227 """ 228 all_sections = [ 229 LineType.FILE_LEVEL, 230 LineType.INCLUDE, 231 LineType.FILE_RULE, 232 LineType.WILDCARD, 233 LineType.USER, 234 LineType.PER_FILE, 235 LineType.TRAILING_COMMENTS, 236 ] 237 formatted_lines: List[str] = [] 238 239 def append_section(line_type): 240 # Add a line of separation if there was a previous section and our 241 # current section has any content. I.e. do not lead with padding and 242 # do not have multiple successive lines of padding. 243 if ( 244 formatted_lines 245 and line_type != LineType.TRAILING_COMMENTS 246 and sections[line_type] 247 ): 248 formatted_lines.append("") 249 250 sections[line_type].sort(key=lambda line: line.content) 251 for line in sections[line_type]: 252 # Strip keep-sorted comments out since sorting is done by this 253 # script 254 formatted_lines.extend( 255 [ 256 comment 257 for comment in line.comments 258 if not comment.startswith("# keep-sorted: ") 259 ] 260 ) 261 formatted_lines.append(line.content) 262 263 for section in all_sections: 264 append_section(section) 265 266 if any(section_name not in all_sections for section_name in sections): 267 raise FormatterError("Formatter did not process all sections.") 268 return formatted_lines 269 270 def check_style(self) -> None: 271 """Checks styling of OWNERS file. 272 273 Enforce consistent style on OWNERS file. This also incidentally detects 274 a few classes of errors. 275 276 Raises: 277 OwnersStyleError: Indicates styled lines do not match original input. 278 """ 279 280 if self.original_lines != self.formatted_lines: 281 print( 282 "\n".join( 283 difflib.unified_diff( 284 self.original_lines, 285 self.formatted_lines, 286 fromfile=str(self.path), 287 tofile="styled", 288 lineterm="", 289 ) 290 ) 291 ) 292 293 raise OwnersStyleError( 294 "OWNERS file format does not follow styling." 295 ) 296 297 def look_for_owners_errors(self) -> None: 298 """Scans owners files for invalid or useless content.""" 299 300 # Confirm when using the wildcard("*") we don't also try to use 301 # individual user grants. 302 if self.sections[LineType.WILDCARD] and self.sections[LineType.USER]: 303 raise OwnersUserGrantError( 304 "Do not use '*' with individual user " 305 "grants, * already applies to all users." 306 ) 307 308 # NOTE: Using the include keyword in combination with a per-file rule is 309 # not possible. 310 # 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. 311 if self.sections[LineType.INCLUDE] and self.sections[LineType.PER_FILE]: 312 raise OwnersProhibitedError( 313 "'include' cannot be used with 'per-file'." 314 ) 315 316 def __complete_path(self, sub_owners_file_path) -> pathlib.Path: 317 """Always return absolute path.""" 318 # Absolute paths start with the git/project root 319 if sub_owners_file_path.startswith("/"): 320 root = git_repo.root(self.path) 321 full_path = root / sub_owners_file_path[1:] 322 else: 323 # Relative paths start with owners file dir 324 full_path = self.path.parent / sub_owners_file_path 325 return full_path.resolve() 326 327 def get_dependencies(self) -> List[pathlib.Path]: 328 """Finds owners files this file includes.""" 329 dependencies = [] 330 # All the includes 331 for include in self.sections.get(LineType.INCLUDE, []): 332 file_str = include.content[len("include ") :] 333 dependencies.append(self.__complete_path(file_str)) 334 335 # all file: rules: 336 for file_rule in self.sections.get(LineType.FILE_RULE, []): 337 file_str = file_rule.content[len("file:") :] 338 if ":" in file_str: 339 _LOG.warning( 340 "TODO(b/254322931): This check does not yet support " 341 "<project> or <branch> in a file: rule" 342 ) 343 _LOG.warning( 344 "It will not check line '%s' found in %s", 345 file_rule.content, 346 self.path, 347 ) 348 349 dependencies.append(self.__complete_path(file_str)) 350 351 # all the per-file rule includes 352 for per_file in self.sections.get(LineType.PER_FILE, []): 353 file_str = per_file.content[len("per-file ") :] 354 access_grant = file_str[file_str.index("=") + 1 :] 355 if access_grant.startswith("file:"): 356 dependencies.append( 357 self.__complete_path(access_grant[len("file:") :]) 358 ) 359 360 return dependencies 361 362 def write_formatted(self) -> None: 363 self.path.write_text("\n".join(self.formatted_lines)) 364 365 366def resolve_owners_tree(root_owners: pathlib.Path) -> List[OwnersFile]: 367 """Given a starting OWNERS file return it and all of it's dependencies.""" 368 found = [] 369 todo = collections.deque((root_owners,)) 370 checked: Set[pathlib.Path] = set() 371 while todo: 372 cur_file = todo.popleft() 373 checked.add(cur_file) 374 owners_obj = OwnersFile(cur_file) 375 found.append(owners_obj) 376 new_dependents = owners_obj.get_dependencies() 377 for new_dep in new_dependents: 378 if new_dep not in checked and new_dep not in todo: 379 todo.append(new_dep) 380 return found 381 382 383def _run_owners_checks(owners_obj: OwnersFile) -> None: 384 owners_obj.look_for_owners_errors() 385 owners_obj.check_style() 386 387 388def _format_owners_file(owners_obj: OwnersFile) -> None: 389 owners_obj.look_for_owners_errors() 390 391 if owners_obj.original_lines != owners_obj.formatted_lines: 392 owners_obj.write_formatted() 393 394 395def _list_unwrapper( 396 func, list_or_path: Union[Iterable[pathlib.Path], pathlib.Path] 397) -> Dict[pathlib.Path, str]: 398 """Decorator that accepts Paths or list of Paths and iterates as needed.""" 399 errors: Dict[pathlib.Path, str] = {} 400 if isinstance(list_or_path, Iterable): 401 files = list_or_path 402 else: 403 files = (list_or_path,) 404 405 all_owners_obj: List[OwnersFile] = [] 406 for file in files: 407 all_owners_obj.extend(resolve_owners_tree(file)) 408 409 checked: Set[pathlib.Path] = set() 410 for current_owners in all_owners_obj: 411 # Ensure we don't check the same file twice 412 if current_owners.path in checked: 413 continue 414 checked.add(current_owners.path) 415 try: 416 func(current_owners) 417 except OwnersError as err: 418 errors[current_owners.path] = err.message 419 _LOG.error( 420 "%s: %s", str(current_owners.path.absolute()), err.message 421 ) 422 return errors 423 424 425# This generates decorated versions of the functions that can used with both 426# formatter (which supplies files one at a time) and presubmits (which supplies 427# list of files). 428run_owners_checks = functools.partial(_list_unwrapper, _run_owners_checks) 429format_owners_file = functools.partial(_list_unwrapper, _format_owners_file) 430 431 432def presubmit_check( 433 files: Union[pathlib.Path, Collection[pathlib.Path]] 434) -> None: 435 errors = run_owners_checks(files) 436 if errors: 437 for file in errors: 438 _LOG.warning(" pw format --fix %s", file) 439 _LOG.warning("will automatically fix this.") 440 raise PresubmitFailure 441 442 443def main() -> int: 444 """Standalone test of styling.""" 445 parser = argparse.ArgumentParser() 446 parser.add_argument("--style", action="store_true") 447 parser.add_argument("--owners_file", required=True, type=str) 448 args = parser.parse_args() 449 450 try: 451 owners_obj = OwnersFile(pathlib.Path(args.owners_file)) 452 owners_obj.look_for_owners_errors() 453 owners_obj.check_style() 454 except OwnersError as err: 455 _LOG.error("%s %s", err, err.message) 456 return 1 457 return 0 458 459 460if __name__ == "__main__": 461 sys.exit(main()) 462