|
| 1 | +import re |
| 2 | +from pathlib import Path |
| 3 | +import sys |
| 4 | +import _colorize |
| 5 | +import textwrap |
| 6 | + |
| 7 | +SIMPLE_FUNCTION_REGEX = re.compile(r"PyAPI_FUNC(.+) (\w+)\(") |
| 8 | +SIMPLE_MACRO_REGEX = re.compile(r"# *define *(\w+)(\(.+\))? ") |
| 9 | +SIMPLE_INLINE_REGEX = re.compile(r"static inline .+( |\n)(\w+)") |
| 10 | +SIMPLE_DATA_REGEX = re.compile(r"PyAPI_DATA\(.+\) (\w+)") |
| 11 | + |
| 12 | +CPYTHON = Path(__file__).parent.parent.parent |
| 13 | +INCLUDE = CPYTHON / "Include" |
| 14 | +C_API_DOCS = CPYTHON / "Doc" / "c-api" |
| 15 | +IGNORED = ( |
| 16 | + (CPYTHON / "Tools" / "check-c-api-docs" / "ignored_c_api.txt") |
| 17 | + .read_text() |
| 18 | + .split("\n") |
| 19 | +) |
| 20 | + |
| 21 | +for index, line in enumerate(IGNORED): |
| 22 | + if line.startswith("#"): |
| 23 | + IGNORED.pop(index) |
| 24 | + |
| 25 | +MISTAKE = """ |
| 26 | +If this is a mistake and this script should not be failing, create an |
| 27 | +issue and tag Peter (@ZeroIntensity) on it.\ |
| 28 | +""" |
| 29 | + |
| 30 | + |
| 31 | +def found_undocumented(singular: bool) -> str: |
| 32 | + some = "an" if singular else "some" |
| 33 | + s = "" if singular else "s" |
| 34 | + these = "this" if singular else "these" |
| 35 | + them = "it" if singular else "them" |
| 36 | + were = "was" if singular else "were" |
| 37 | + |
| 38 | + return ( |
| 39 | + textwrap.dedent( |
| 40 | + f""" |
| 41 | + Found {some} undocumented C API{s}! |
| 42 | +
|
| 43 | + Python requires documentation on all public C API symbols, macros, and types. |
| 44 | + If {these} API{s} {were} not meant to be public, prefix {them} with a |
| 45 | + leading underscore (_PySomething_API) or move {them} to the internal C API |
| 46 | + (pycore_*.h files). |
| 47 | +
|
| 48 | + In exceptional cases, certain APIs can be ignored by adding them to |
| 49 | + Tools/check-c-api-docs/ignored_c_api.txt |
| 50 | + """ |
| 51 | + ) |
| 52 | + + MISTAKE |
| 53 | + ) |
| 54 | + |
| 55 | + |
| 56 | +def found_ignored_documented(singular: bool) -> str: |
| 57 | + some = "a" if singular else "some" |
| 58 | + s = "" if singular else "s" |
| 59 | + them = "it" if singular else "them" |
| 60 | + were = "was" if singular else "were" |
| 61 | + they = "it" if singular else "they" |
| 62 | + |
| 63 | + return ( |
| 64 | + textwrap.dedent( |
| 65 | + f""" |
| 66 | + Found {some} C API{s} listed in Tools/c-api-docs-check/ignored_c_api.txt, but |
| 67 | + {they} {were} found in the documentation. To fix this, remove {them} from |
| 68 | + ignored_c_api.txt. |
| 69 | + """ |
| 70 | + ) |
| 71 | + + MISTAKE |
| 72 | + ) |
| 73 | + |
| 74 | + |
| 75 | +def is_documented(name: str) -> bool: |
| 76 | + """ |
| 77 | + Is a name present in the C API documentation? |
| 78 | + """ |
| 79 | + for path in C_API_DOCS.iterdir(): |
| 80 | + if path.is_dir(): |
| 81 | + continue |
| 82 | + if path.suffix != ".rst": |
| 83 | + continue |
| 84 | + |
| 85 | + text = path.read_text(encoding="utf-8") |
| 86 | + if name in text: |
| 87 | + return True |
| 88 | + |
| 89 | + return False |
| 90 | + |
| 91 | + |
| 92 | +def scan_file_for_docs(filename: str, text: str) -> tuple[list[str], list[str]]: |
| 93 | + """ |
| 94 | + Scan a header file for C API functions. |
| 95 | + """ |
| 96 | + undocumented: list[str] = [] |
| 97 | + documented_ignored: list[str] = [] |
| 98 | + colors = _colorize.get_colors() |
| 99 | + |
| 100 | + def check_for_name(name: str) -> None: |
| 101 | + documented = is_documented(name) |
| 102 | + if documented and (name in IGNORED): |
| 103 | + documented_ignored.append(name) |
| 104 | + elif not documented and (name not in IGNORED): |
| 105 | + undocumented.append(name) |
| 106 | + |
| 107 | + for function in SIMPLE_FUNCTION_REGEX.finditer(text): |
| 108 | + name = function.group(2) |
| 109 | + if not name.startswith("Py"): |
| 110 | + continue |
| 111 | + |
| 112 | + check_for_name(name) |
| 113 | + |
| 114 | + for macro in SIMPLE_MACRO_REGEX.finditer(text): |
| 115 | + name = macro.group(1) |
| 116 | + if not name.startswith("Py"): |
| 117 | + continue |
| 118 | + |
| 119 | + if "(" in name: |
| 120 | + name = name[: name.index("(")] |
| 121 | + |
| 122 | + check_for_name(name) |
| 123 | + |
| 124 | + for inline in SIMPLE_INLINE_REGEX.finditer(text): |
| 125 | + name = inline.group(2) |
| 126 | + if not name.startswith("Py"): |
| 127 | + continue |
| 128 | + |
| 129 | + check_for_name(name) |
| 130 | + |
| 131 | + for data in SIMPLE_DATA_REGEX.finditer(text): |
| 132 | + name = data.group(1) |
| 133 | + if not name.startswith("Py"): |
| 134 | + continue |
| 135 | + |
| 136 | + check_for_name(name) |
| 137 | + |
| 138 | + # Remove duplicates and sort alphabetically to keep the output deterministic |
| 139 | + undocumented = list(set(undocumented)) |
| 140 | + undocumented.sort() |
| 141 | + |
| 142 | + if undocumented or documented_ignored: |
| 143 | + print(f"{filename} {colors.RED}BAD{colors.RESET}") |
| 144 | + for name in undocumented: |
| 145 | + print(f"{colors.BOLD_RED}UNDOCUMENTED:{colors.RESET} {name}") |
| 146 | + for name in documented_ignored: |
| 147 | + print(f"{colors.BOLD_YELLOW}DOCUMENTED BUT IGNORED:{colors.RESET} {name}") |
| 148 | + else: |
| 149 | + print(f"{filename} {colors.GREEN}OK{colors.RESET}") |
| 150 | + |
| 151 | + return undocumented, documented_ignored |
| 152 | + |
| 153 | + |
| 154 | +def main() -> None: |
| 155 | + print("Scanning for undocumented C API functions...") |
| 156 | + files = [*INCLUDE.iterdir(), *(INCLUDE / "cpython").iterdir()] |
| 157 | + all_missing: list[str] = [] |
| 158 | + all_found_ignored: list[str] = [] |
| 159 | + |
| 160 | + for file in files: |
| 161 | + if file.is_dir(): |
| 162 | + continue |
| 163 | + assert file.exists() |
| 164 | + text = file.read_text(encoding="utf-8") |
| 165 | + missing, ignored = scan_file_for_docs(str(file.relative_to(INCLUDE)), text) |
| 166 | + all_found_ignored += ignored |
| 167 | + all_missing += missing |
| 168 | + |
| 169 | + fail = False |
| 170 | + to_check = [ |
| 171 | + (all_missing, "missing", found_undocumented(len(all_missing) == 1)), |
| 172 | + ( |
| 173 | + all_found_ignored, |
| 174 | + "documented but ignored", |
| 175 | + found_ignored_documented(len(all_found_ignored) == 1), |
| 176 | + ), |
| 177 | + ] |
| 178 | + for name_list, what, message in to_check: |
| 179 | + if not name_list: |
| 180 | + continue |
| 181 | + |
| 182 | + s = "s" if len(name_list) != 1 else "" |
| 183 | + print(f"-- {len(name_list)} {what} C API{s} --") |
| 184 | + for name in name_list: |
| 185 | + print(f" - {name}") |
| 186 | + print(message) |
| 187 | + fail = True |
| 188 | + |
| 189 | + sys.exit(1 if fail else 0) |
| 190 | + |
| 191 | + |
| 192 | +if __name__ == "__main__": |
| 193 | + main() |
0 commit comments