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