• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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