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 = f""" 134:white_check_mark: With the latest revision this PR passed the {self.friendly_name}. 135""" 136 self.update_pr(comment_text, args, create_new=False) 137 return True 138 elif len(diff) > 0: 139 if should_update_gh: 140 comment_text = self.pr_comment_text_for_diff(diff) 141 self.update_pr(comment_text, args, create_new=True) 142 else: 143 print( 144 f"Warning: {self.friendly_name}, {self.name} detected some issues with your code formatting..." 145 ) 146 return False 147 else: 148 # The formatter failed but didn't output a diff (e.g. some sort of 149 # infrastructure failure). 150 comment_text = f""" 151:warning: The {self.friendly_name} failed without printing a diff. Check the logs for stderr output. :warning: 152""" 153 self.update_pr(comment_text, args, create_new=False) 154 return False 155 156 157class ClangFormatHelper(FormatHelper): 158 name = "clang-format" 159 friendly_name = "C/C++ code formatter" 160 161 @property 162 def instructions(self) -> str: 163 return " ".join(self.cf_cmd) 164 165 def should_include_extensionless_file(self, path: str) -> bool: 166 return path.startswith("libcxx/include") 167 168 def filter_changed_files(self, changed_files: List[str]) -> List[str]: 169 filtered_files = [] 170 for path in changed_files: 171 _, ext = os.path.splitext(path) 172 if ext in (".cpp", ".c", ".h", ".hpp", ".hxx", ".cxx", ".inc", ".cppm"): 173 filtered_files.append(path) 174 elif ext == "" and self.should_include_extensionless_file(path): 175 filtered_files.append(path) 176 return filtered_files 177 178 @property 179 def clang_fmt_path(self) -> str: 180 if "CLANG_FORMAT_PATH" in os.environ: 181 return os.environ["CLANG_FORMAT_PATH"] 182 return "git-clang-format" 183 184 def has_tool(self) -> bool: 185 cmd = [self.clang_fmt_path, "-h"] 186 proc = None 187 try: 188 proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 189 except: 190 return False 191 return proc.returncode == 0 192 193 def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]: 194 cpp_files = self.filter_changed_files(changed_files) 195 if not cpp_files: 196 return None 197 198 cf_cmd = [self.clang_fmt_path, "--diff"] 199 200 if args.start_rev and args.end_rev: 201 cf_cmd.append(args.start_rev) 202 cf_cmd.append(args.end_rev) 203 204 cf_cmd.append("--") 205 cf_cmd += cpp_files 206 207 if args.verbose: 208 print(f"Running: {' '.join(cf_cmd)}") 209 self.cf_cmd = cf_cmd 210 proc = subprocess.run(cf_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 211 sys.stdout.write(proc.stderr.decode("utf-8")) 212 213 if proc.returncode != 0: 214 # formatting needed, or the command otherwise failed 215 if args.verbose: 216 print(f"error: {self.name} exited with code {proc.returncode}") 217 # Print the diff in the log so that it is viewable there 218 print(proc.stdout.decode("utf-8")) 219 return proc.stdout.decode("utf-8") 220 else: 221 return None 222 223 224class DarkerFormatHelper(FormatHelper): 225 name = "darker" 226 friendly_name = "Python code formatter" 227 228 @property 229 def instructions(self) -> str: 230 return " ".join(self.darker_cmd) 231 232 def filter_changed_files(self, changed_files: List[str]) -> List[str]: 233 filtered_files = [] 234 for path in changed_files: 235 name, ext = os.path.splitext(path) 236 if ext == ".py": 237 filtered_files.append(path) 238 239 return filtered_files 240 241 @property 242 def darker_fmt_path(self) -> str: 243 if "DARKER_FORMAT_PATH" in os.environ: 244 return os.environ["DARKER_FORMAT_PATH"] 245 return "darker" 246 247 def has_tool(self) -> bool: 248 cmd = [self.darker_fmt_path, "--version"] 249 proc = None 250 try: 251 proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 252 except: 253 return False 254 return proc.returncode == 0 255 256 def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]: 257 py_files = self.filter_changed_files(changed_files) 258 if not py_files: 259 return None 260 darker_cmd = [ 261 self.darker_fmt_path, 262 "--check", 263 "--diff", 264 ] 265 if args.start_rev and args.end_rev: 266 darker_cmd += ["-r", f"{args.start_rev}...{args.end_rev}"] 267 darker_cmd += py_files 268 if args.verbose: 269 print(f"Running: {' '.join(darker_cmd)}") 270 self.darker_cmd = darker_cmd 271 proc = subprocess.run( 272 darker_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE 273 ) 274 if args.verbose: 275 sys.stdout.write(proc.stderr.decode("utf-8")) 276 277 if proc.returncode != 0: 278 # formatting needed, or the command otherwise failed 279 if args.verbose: 280 print(f"error: {self.name} exited with code {proc.returncode}") 281 # Print the diff in the log so that it is viewable there 282 print(proc.stdout.decode("utf-8")) 283 return proc.stdout.decode("utf-8") 284 else: 285 sys.stdout.write(proc.stdout.decode("utf-8")) 286 return None 287 288 289ALL_FORMATTERS = (DarkerFormatHelper(), ClangFormatHelper()) 290 291 292def hook_main(): 293 # fill out args 294 args = FormatArgs() 295 args.verbose = False 296 297 # find the changed files 298 cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=d"] 299 proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 300 output = proc.stdout.decode("utf-8") 301 for line in output.splitlines(): 302 args.changed_files.append(line) 303 304 failed_fmts = [] 305 for fmt in ALL_FORMATTERS: 306 if fmt.has_tool(): 307 if not fmt.run(args.changed_files, args): 308 failed_fmts.append(fmt.name) 309 else: 310 print(f"Couldn't find {fmt.name}, can't check " + fmt.friendly_name.lower()) 311 312 if len(failed_fmts) > 0: 313 sys.exit(1) 314 315 sys.exit(0) 316 317 318if __name__ == "__main__": 319 script_path = os.path.abspath(__file__) 320 if ".git/hooks" in script_path: 321 hook_main() 322 sys.exit(0) 323 324 parser = argparse.ArgumentParser() 325 parser.add_argument( 326 "--token", type=str, required=True, help="GitHub authentiation token" 327 ) 328 parser.add_argument( 329 "--repo", 330 type=str, 331 default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"), 332 help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)", 333 ) 334 parser.add_argument("--issue-number", type=int, required=True) 335 parser.add_argument( 336 "--start-rev", 337 type=str, 338 required=True, 339 help="Compute changes from this revision.", 340 ) 341 parser.add_argument( 342 "--end-rev", type=str, required=True, help="Compute changes to this revision" 343 ) 344 parser.add_argument( 345 "--changed-files", 346 type=str, 347 help="Comma separated list of files that has been changed", 348 ) 349 350 args = FormatArgs(parser.parse_args()) 351 352 changed_files = [] 353 if args.changed_files: 354 changed_files = args.changed_files.split(",") 355 356 failed_formatters = [] 357 for fmt in ALL_FORMATTERS: 358 if not fmt.run(changed_files, args): 359 failed_formatters.append(fmt.name) 360 361 if len(failed_formatters) > 0: 362 print(f"error: some formatters failed: {' '.join(failed_formatters)}") 363 sys.exit(1) 364