|
| 1 | +import pathlib |
| 2 | +import re |
| 3 | +import sys |
| 4 | +from functools import cached_property |
| 5 | +from typing import Dict, Iterator, List, Pattern, Set, Tuple |
| 6 | + |
| 7 | +import abstracts |
| 8 | + |
| 9 | +from envoy.code.check import abstract |
| 10 | + |
| 11 | +from aio.core.functional import async_property |
| 12 | + |
| 13 | + |
| 14 | +INVALID_REFLINK = r".* ref:.*" |
| 15 | +REF_WITH_PUNCTUATION_REGEX = r".*\. <[^<]*>`\s*" |
| 16 | +VERSION_HISTORY_NEW_LINE_REGEX = r"\* ([0-9a-z \-_]+): ([a-z:`]+)" |
| 17 | +VERSION_HISTORY_SECTION_NAME = r"^[A-Z][A-Za-z ]*$" |
| 18 | +# Make sure backticks come in pairs. |
| 19 | +# Exceptions: reflinks (ref:`` where the backtick won't be preceded by a space |
| 20 | +# links `title <link>`_ where the _ is checked for in the regex. |
| 21 | +BAD_TICKS_REGEX = re.compile(r".* `[^`].*`[^_]") |
| 22 | + |
| 23 | +# TODO(phlax): |
| 24 | +# - generalize these checks to all rst files |
| 25 | +# - improve checks/handling of "default role"/inline literals |
| 26 | +# (perhaps using a sphinx plugin) |
| 27 | +# - add rstcheck and/or rstlint |
| 28 | + |
| 29 | + |
| 30 | +class CurrentVersionFile: |
| 31 | + |
| 32 | + def __init__(self, path: pathlib.Path): |
| 33 | + self._path = path |
| 34 | + |
| 35 | + @cached_property |
| 36 | + def backticks_re(self) -> Pattern[str]: |
| 37 | + return re.compile(BAD_TICKS_REGEX) |
| 38 | + |
| 39 | + @cached_property |
| 40 | + def invalid_reflink_re(self) -> Pattern[str]: |
| 41 | + return re.compile(INVALID_REFLINK) |
| 42 | + |
| 43 | + @property |
| 44 | + def lines(self) -> Iterator[str]: |
| 45 | + with open(self.path) as f: |
| 46 | + for line in f.readlines(): |
| 47 | + yield line.strip() |
| 48 | + |
| 49 | + @cached_property |
| 50 | + def new_line_re(self) -> Pattern[str]: |
| 51 | + return re.compile(VERSION_HISTORY_NEW_LINE_REGEX) |
| 52 | + |
| 53 | + @property |
| 54 | + def path(self) -> pathlib.Path: |
| 55 | + return self._path |
| 56 | + |
| 57 | + @property |
| 58 | + def prior_endswith_period(self) -> bool: |
| 59 | + return bool( |
| 60 | + self.prior_line.endswith(".") |
| 61 | + # Don't punctuation-check empty lines. |
| 62 | + or not self.prior_line |
| 63 | + # The text in the :ref ends with a . |
| 64 | + or (self.prior_line.endswith('`') |
| 65 | + and self.punctuation_re.match(self.prior_line))) |
| 66 | + |
| 67 | + @cached_property |
| 68 | + def punctuation_re(self) -> Pattern[str]: |
| 69 | + return re.compile(REF_WITH_PUNCTUATION_REGEX) |
| 70 | + |
| 71 | + @cached_property |
| 72 | + def section_name_re(self) -> Pattern[str]: |
| 73 | + return re.compile(VERSION_HISTORY_SECTION_NAME) |
| 74 | + |
| 75 | + def check_line(self, line: str) -> List[str]: |
| 76 | + errors = self.check_reflink(line) + self.check_ticks(line) |
| 77 | + if line.startswith("* "): |
| 78 | + errors += self.check_list_item(line) |
| 79 | + elif not line: |
| 80 | + # If we hit the end of this release note block block, check the prior line. |
| 81 | + errors += self.check_previous_period() |
| 82 | + self.prior_line = '' |
| 83 | + elif self.prior_line: |
| 84 | + self.prior_line += line |
| 85 | + return errors |
| 86 | + |
| 87 | + def check_list_item(self, line: str) -> List[str]: |
| 88 | + errors = [] |
| 89 | + if not self.prior_endswith_period: |
| 90 | + errors.append(f"The following release note does not end with a '.'\n {self.prior_line}") |
| 91 | + |
| 92 | + match = self.new_line_re.match(line) |
| 93 | + if not match: |
| 94 | + return errors + [ |
| 95 | + "Version history line malformed. " |
| 96 | + f"Does not match VERSION_HISTORY_NEW_LINE_REGEX\n {line}\n" |
| 97 | + "Please use messages in the form 'category: feature explanation.', " |
| 98 | + "starting with a lower-cased letter and ending with a period." |
| 99 | + ] |
| 100 | + first_word = match.groups()[0] |
| 101 | + next_word = match.groups()[1] |
| 102 | + |
| 103 | + # Do basic alphabetization checks of the first word on the line and the |
| 104 | + # first word after the : |
| 105 | + if self.first_word_of_prior_line and self.first_word_of_prior_line > first_word: |
| 106 | + errors.append( |
| 107 | + f"Version history not in alphabetical order " |
| 108 | + f"({self.first_word_of_prior_line} vs {first_word}): " |
| 109 | + f"please check placement of line\n {line}. ") |
| 110 | + if self.first_word_of_prior_line == first_word and self.next_word_to_check and self.next_word_to_check > next_word: |
| 111 | + errors.append( |
| 112 | + f"Version history not in alphabetical order " |
| 113 | + f"({self.next_word_to_check} vs {next_word}): " |
| 114 | + f"please check placement of line\n {line}. ") |
| 115 | + self.set_list_tokens(line, first_word, next_word) |
| 116 | + return errors |
| 117 | + |
| 118 | + def check_previous_period(self) -> List[str]: |
| 119 | + return ([f"The following release note does not end with a '.'\n {self.prior_line}"] |
| 120 | + if not self.prior_endswith_period else []) |
| 121 | + |
| 122 | + def check_reflink(self, line: str) -> List[str]: |
| 123 | + return ([f"Found text \" ref:\". This should probably be \" :ref:\"\n{line}"] |
| 124 | + if self.invalid_reflink_re.match(line) else []) |
| 125 | + |
| 126 | + def check_ticks(self, line: str) -> List[str]: |
| 127 | + return ([ |
| 128 | + f"Backticks should come in pairs (``foo``) except for links (`title <url>`_) or refs (ref:`text <ref>`): {line}" |
| 129 | + ] if (self.backticks_re.match(line)) else []) |
| 130 | + |
| 131 | + def run_checks(self) -> Iterator[str]: |
| 132 | + for line_number, line in enumerate(self.lines): |
| 133 | + if self.section_name_re.match(line): |
| 134 | + if line == "Deprecated": |
| 135 | + break |
| 136 | + self.reset_list_tokens() |
| 137 | + for error in self.check_line(line): |
| 138 | + yield f"({self.path}:{line_number + 1}) {error}" |
| 139 | + |
| 140 | + def reset_list_tokens(self) -> None: |
| 141 | + self.set_list_tokens("", "", "") |
| 142 | + |
| 143 | + def set_list_tokens(self, line: str, first_word: str, next_word: str) -> None: |
| 144 | + self._list_tokens.update(dict(line=line, first_word=first_word, next_word=next_word)) |
| 145 | + |
| 146 | + @cached_property |
| 147 | + def _list_tokens(self) -> Dict[str, str]: |
| 148 | + return dict(line="", first_word="", next_word="") |
| 149 | + |
| 150 | + |
| 151 | +class AVersionHistoryCheck(abstract.ACodeCheck, metaclass=abstracts.Abstraction): |
| 152 | + |
| 153 | + @async_property |
| 154 | + async def checker_files(self) -> Set[str]: |
| 155 | + return set(["docs/root/version_history/current.rst"]) |
| 156 | + |
| 157 | + @async_property(cache=True) |
| 158 | + async def problem_files(self) -> Dict[str, List[str]]: |
| 159 | + return ( |
| 160 | + dict(await self.errors) |
| 161 | + if await self.files |
| 162 | + else {}) |
| 163 | + |
| 164 | + @async_property |
| 165 | + async def errors(self): |
| 166 | + return { |
| 167 | + version_history: list( |
| 168 | + CurrentVersionFile(pathlib.Path(version_history)).run_checks()) |
| 169 | + for version_history |
| 170 | + in await self.absolute_paths} |
0 commit comments