1#!/usr/bin/env python3 2# 3# ====- code-format-helper, runs code formatters from the ci or in a hook --*- python -*--==# 4# 5# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. 6# See https://llvm.org/LICENSE.txt for license information. 7# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception 8# 9# ==--------------------------------------------------------------------------------------==# 10 11import argparse 12import os 13import subprocess 14import sys 15from typing import List, Optional 16 17""" 18This script is run by GitHub actions to ensure that the code in PR's conform to 19the coding style of LLVM. It can also be installed as a pre-commit git hook to 20check the coding style before submitting it. The canonical source of this script 21is in the LLVM source tree under llvm/utils/git. 22 23For C/C++ code it uses clang-format and for Python code it uses darker (which 24in turn invokes black). 25 26You can learn more about the LLVM coding style on llvm.org: 27https://llvm.org/docs/CodingStandards.html 28 29You can install this script as a git hook by symlinking it to the .git/hooks 30directory: 31 32ln -s $(pwd)/llvm/utils/git/code-format-helper.py .git/hooks/pre-commit 33 34You can control the exact path to clang-format or darker with the following 35environment variables: $CLANG_FORMAT_PATH and $DARKER_FORMAT_PATH. 36""" 37 38 39class FormatArgs: 40 start_rev: str = None 41 end_rev: str = None 42 repo: str = None 43 changed_files: List[str] = [] 44 token: str = None 45 verbose: bool = True 46 issue_number: int = 0 47 48 def __init__(self, args: argparse.Namespace = None) -> None: 49 if not args is None: 50 self.start_rev = args.start_rev 51 self.end_rev = args.end_rev 52 self.repo = args.repo 53 self.token = args.token 54 self.changed_files = args.changed_files 55 self.issue_number = args.issue_number 56 57 58class FormatHelper: 59 COMMENT_TAG = "<!--LLVM CODE FORMAT COMMENT: {fmt}-->" 60 name: str 61 friendly_name: str 62 63 @property 64 def comment_tag(self) -> str: 65 return self.COMMENT_TAG.replace("fmt", self.name) 66 67 @property 68 def instructions(self) -> str: 69 raise NotImplementedError() 70 71 def has_tool(self) -> bool: 72 raise NotImplementedError() 73 74 def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]: 75 raise NotImplementedError() 76 77 def pr_comment_text_for_diff(self, diff: str) -> str: 78 return f""" 79:warning: {self.friendly_name}, {self.name} found issues in your code. :warning: 80 81<details> 82<summary> 83You can test this locally with the following command: 84</summary> 85 86``````````bash 87{self.instructions} 88`````````` 89 90</details> 91 92<details> 93<summary> 94View the diff from {self.name} here. 95</summary> 96 97``````````diff 98{diff} 99`````````` 100 101</details> 102""" 103 104 # TODO: any type should be replaced with the correct github type, but it requires refactoring to 105 # not require the github module to be installed everywhere. 106 def find_comment(self, pr: any) -> any: 107 for comment in pr.as_issue().get_comments(): 108 if self.comment_tag in comment.body: 109 return comment 110 return None 111 112 def update_pr(self, comment_text: str, args: FormatArgs, create_new: bool) -> None: 113 import github 114 from github import IssueComment, PullRequest 115 116 repo = github.Github(args.token).get_repo(args.repo) 117 pr = repo.get_issue(args.issue_number).as_pull_request() 118 119 comment_text = self.comment_tag + "\n\n" + comment_text 120 121 existing_comment = self.find_comment(pr) 122 if existing_comment: 123 existing_comment.edit(comment_text) 124 elif create_new: 125 pr.as_issue().create_comment(comment_text) 126 127 def run(self, changed_files: List[str], args: FormatArgs) -> bool: 128 diff = self.format_run(changed_files, args) 129 should_update_gh = args.token is not None and args.repo is not None 130 131 if diff is None: 132 if should_update_gh: 133 comment_text = ( 134 ":white_check_mark: With the latest revision " 135 f"this PR passed the {self.friendly_name}." 136 ) 137 self.update_pr(comment_text, args, create_new=False) 138 return True 139 elif len(diff) > 0: 140 if should_update_gh: 141 comment_text = self.pr_comment_text_for_diff(diff) 142 self.update_pr(comment_text, args, create_new=True) 143 else: 144 print( 145 f"Warning: {self.friendly_name}, {self.name} detected " 146 "some issues with your code formatting..." 147 ) 148 return False 149 else: 150 # The formatter failed but didn't output a diff (e.g. some sort of 151 # infrastructure failure). 152 comment_text = ( 153 f":warning: The {self.friendly_name} failed without printing " 154 "a diff. Check the logs for stderr output. :warning:" 155 ) 156 self.update_pr(comment_text, args, create_new=False) 157 return False 158 159 160class ClangFormatHelper(FormatHelper): 161 name = "clang-format" 162 friendly_name = "C/C++ code formatter" 163 164 @property 165 def instructions(self) -> str: 166 return " ".join(self.cf_cmd) 167 168 def should_include_extensionless_file(self, path: str) -> bool: 169 return path.startswith("libcxx/include") 170 171 def filter_changed_files(self, changed_files: List[str]) -> List[str]: 172 filtered_files = [] 173 for path in changed_files: 174 _, ext = os.path.splitext(path) 175 if ext in (".cpp", ".c", ".h", ".hpp", ".hxx", ".cxx", ".inc", ".cppm"): 176 filtered_files.append(path) 177 elif ext == "" and self.should_include_extensionless_file(path): 178 filtered_files.append(path) 179 return filtered_files 180 181 @property 182 def clang_fmt_path(self) -> str: 183 if "CLANG_FORMAT_PATH" in os.environ: 184 return os.environ["CLANG_FORMAT_PATH"] 185 return "git-clang-format" 186 187 def has_tool(self) -> bool: 188 cmd = [self.clang_fmt_path, "-h"] 189 proc = None 190 try: 191 proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 192 except: 193 return False 194 return proc.returncode == 0 195 196 def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]: 197 cpp_files = self.filter_changed_files(changed_files) 198 if not cpp_files: 199 return None 200 201 cf_cmd = [self.clang_fmt_path, "--diff"] 202 203 if args.start_rev and args.end_rev: 204 cf_cmd.append(args.start_rev) 205 cf_cmd.append(args.end_rev) 206 207 cf_cmd.append("--") 208 cf_cmd += cpp_files 209 210 if args.verbose: 211 print(f"Running: {' '.join(cf_cmd)}") 212 self.cf_cmd = cf_cmd 213 proc = subprocess.run(cf_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 214 sys.stdout.write(proc.stderr.decode("utf-8")) 215 216 if proc.returncode != 0: 217 # formatting needed, or the command otherwise failed 218 if args.verbose: 219 print(f"error: {self.name} exited with code {proc.returncode}") 220 # Print the diff in the log so that it is viewable there 221 print(proc.stdout.decode("utf-8")) 222 return proc.stdout.decode("utf-8") 223 else: 224 return None 225 226 227class DarkerFormatHelper(FormatHelper): 228 name = "darker" 229 friendly_name = "Python code formatter" 230 231 @property 232 def instructions(self) -> str: 233 return " ".join(self.darker_cmd) 234 235 def filter_changed_files(self, changed_files: List[str]) -> List[str]: 236 filtered_files = [] 237 for path in changed_files: 238 name, ext = os.path.splitext(path) 239 if ext == ".py": 240 filtered_files.append(path) 241 242 return filtered_files 243 244 @property 245 def darker_fmt_path(self) -> str: 246 if "DARKER_FORMAT_PATH" in os.environ: 247 return os.environ["DARKER_FORMAT_PATH"] 248 return "darker" 249 250 def has_tool(self) -> bool: 251 cmd = [self.darker_fmt_path, "--version"] 252 proc = None 253 try: 254 proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 255 except: 256 return False 257 return proc.returncode == 0 258 259 def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]: 260 py_files = self.filter_changed_files(changed_files) 261 if not py_files: 262 return None 263 darker_cmd = [ 264 self.darker_fmt_path, 265 "--check", 266 "--diff", 267 ] 268 if args.start_rev and args.end_rev: 269 darker_cmd += ["-r", f"{args.start_rev}...{args.end_rev}"] 270 darker_cmd += py_files 271 if args.verbose: 272 print(f"Running: {' '.join(darker_cmd)}") 273 self.darker_cmd = darker_cmd 274 proc = subprocess.run( 275 darker_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE 276 ) 277 if args.verbose: 278 sys.stdout.write(proc.stderr.decode("utf-8")) 279 280 if proc.returncode != 0: 281 # formatting needed, or the command otherwise failed 282 if args.verbose: 283 print(f"error: {self.name} exited with code {proc.returncode}") 284 # Print the diff in the log so that it is viewable there 285 print(proc.stdout.decode("utf-8")) 286 return proc.stdout.decode("utf-8") 287 else: 288 sys.stdout.write(proc.stdout.decode("utf-8")) 289 return None 290 291 292ALL_FORMATTERS = (DarkerFormatHelper(), ClangFormatHelper()) 293 294 295def hook_main(): 296 # fill out args 297 args = FormatArgs() 298 args.verbose = False 299 300 # find the changed files 301 cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=d"] 302 proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 303 output = proc.stdout.decode("utf-8") 304 for line in output.splitlines(): 305 args.changed_files.append(line) 306 307 failed_fmts = [] 308 for fmt in ALL_FORMATTERS: 309 if fmt.has_tool(): 310 if not fmt.run(args.changed_files, args): 311 failed_fmts.append(fmt.name) 312 else: 313 print(f"Couldn't find {fmt.name}, can't check " + fmt.friendly_name.lower()) 314 315 if len(failed_fmts) > 0: 316 sys.exit(1) 317 318 sys.exit(0) 319 320 321if __name__ == "__main__": 322 script_path = os.path.abspath(__file__) 323 if ".git/hooks" in script_path: 324 hook_main() 325 sys.exit(0) 326 327 parser = argparse.ArgumentParser() 328 parser.add_argument( 329 "--token", type=str, required=True, help="GitHub authentiation token" 330 ) 331 parser.add_argument( 332 "--repo", 333 type=str, 334 default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"), 335 help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)", 336 ) 337 parser.add_argument("--issue-number", type=int, required=True) 338 parser.add_argument( 339 "--start-rev", 340 type=str, 341 required=True, 342 help="Compute changes from this revision.", 343 ) 344 parser.add_argument( 345 "--end-rev", type=str, required=True, help="Compute changes to this revision" 346 ) 347 parser.add_argument( 348 "--changed-files", 349 type=str, 350 help="Comma separated list of files that has been changed", 351 ) 352 353 args = FormatArgs(parser.parse_args()) 354 355 changed_files = [] 356 if args.changed_files: 357 changed_files = args.changed_files.split(",") 358 359 failed_formatters = [] 360 for fmt in ALL_FORMATTERS: 361 if not fmt.run(changed_files, args): 362 failed_formatters.append(fmt.name) 363 364 if len(failed_formatters) > 0: 365 print(f"error: some formatters failed: {' '.join(failed_formatters)}") 366 sys.exit(1) 367