llvm-project
394 строки · 12.5 Кб
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 argparse12import os13import subprocess14import sys15from typing import List, Optional16
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:40start_rev: str = None41end_rev: str = None42repo: str = None43changed_files: List[str] = []44token: str = None45verbose: bool = True46issue_number: int = 047write_comment_to_file: bool = False48
49def __init__(self, args: argparse.Namespace = None) -> None:50if not args is None:51self.start_rev = args.start_rev52self.end_rev = args.end_rev53self.repo = args.repo54self.token = args.token55self.changed_files = args.changed_files56self.issue_number = args.issue_number57self.write_comment_to_file = args.write_comment_to_file58
59
60class FormatHelper:61COMMENT_TAG = "<!--LLVM CODE FORMAT COMMENT: {fmt}-->"62name: str63friendly_name: str64comment: dict = None65
66@property67def comment_tag(self) -> str:68return self.COMMENT_TAG.replace("fmt", self.name)69
70@property71def instructions(self) -> str:72raise NotImplementedError()73
74def has_tool(self) -> bool:75raise NotImplementedError()76
77def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]:78raise NotImplementedError()79
80def pr_comment_text_for_diff(self, diff: str) -> str:81return f"""82:warning: {self.friendly_name}, {self.name} found issues in your code. :warning:83
84<details>
85<summary>
86You can test this locally with the following command:
87</summary>
88
89``````````bash
90{self.instructions}91``````````
92
93</details>
94
95<details>
96<summary>
97View the diff from {self.name} here.98</summary>
99
100``````````diff
101{diff}102``````````
103
104</details>
105"""
106
107# TODO: any type should be replaced with the correct github type, but it requires refactoring to108# not require the github module to be installed everywhere.109def find_comment(self, pr: any) -> any:110for comment in pr.as_issue().get_comments():111if self.comment_tag in comment.body:112return comment113return None114
115def update_pr(self, comment_text: str, args: FormatArgs, create_new: bool) -> None:116import github117from github import IssueComment, PullRequest118
119repo = github.Github(args.token).get_repo(args.repo)120pr = repo.get_issue(args.issue_number).as_pull_request()121
122comment_text = self.comment_tag + "\n\n" + comment_text123
124existing_comment = self.find_comment(pr)125
126if args.write_comment_to_file:127if create_new or existing_comment:128self.comment = {"body": comment_text}129if existing_comment:130self.comment["id"] = existing_comment.id131return132
133if existing_comment:134existing_comment.edit(comment_text)135elif create_new:136pr.as_issue().create_comment(comment_text)137
138def run(self, changed_files: List[str], args: FormatArgs) -> bool:139changed_files = [arg for arg in changed_files if "third-party" not in arg]140diff = self.format_run(changed_files, args)141should_update_gh = args.token is not None and args.repo is not None142
143if diff is None:144if should_update_gh:145comment_text = (146":white_check_mark: With the latest revision "147f"this PR passed the {self.friendly_name}."148)149self.update_pr(comment_text, args, create_new=False)150return True151elif len(diff) > 0:152if should_update_gh:153comment_text = self.pr_comment_text_for_diff(diff)154self.update_pr(comment_text, args, create_new=True)155else:156print(157f"Warning: {self.friendly_name}, {self.name} detected "158"some issues with your code formatting..."159)160return False161else:162# The formatter failed but didn't output a diff (e.g. some sort of163# infrastructure failure).164comment_text = (165f":warning: The {self.friendly_name} failed without printing "166"a diff. Check the logs for stderr output. :warning:"167)168self.update_pr(comment_text, args, create_new=False)169return False170
171
172class ClangFormatHelper(FormatHelper):173name = "clang-format"174friendly_name = "C/C++ code formatter"175
176@property177def instructions(self) -> str:178return " ".join(self.cf_cmd)179
180def should_include_extensionless_file(self, path: str) -> bool:181return path.startswith("libcxx/include")182
183def filter_changed_files(self, changed_files: List[str]) -> List[str]:184filtered_files = []185for path in changed_files:186_, ext = os.path.splitext(path)187if ext in (".cpp", ".c", ".h", ".hpp", ".hxx", ".cxx", ".inc", ".cppm"):188filtered_files.append(path)189elif ext == "" and self.should_include_extensionless_file(path):190filtered_files.append(path)191return filtered_files192
193@property194def clang_fmt_path(self) -> str:195if "CLANG_FORMAT_PATH" in os.environ:196return os.environ["CLANG_FORMAT_PATH"]197return "git-clang-format"198
199def has_tool(self) -> bool:200cmd = [self.clang_fmt_path, "-h"]201proc = None202try:203proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)204except:205return False206return proc.returncode == 0207
208def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]:209cpp_files = self.filter_changed_files(changed_files)210if not cpp_files:211return None212
213cf_cmd = [self.clang_fmt_path, "--diff"]214
215if args.start_rev and args.end_rev:216cf_cmd.append(args.start_rev)217cf_cmd.append(args.end_rev)218
219cf_cmd.append("--")220cf_cmd += cpp_files221
222if args.verbose:223print(f"Running: {' '.join(cf_cmd)}")224self.cf_cmd = cf_cmd225proc = subprocess.run(cf_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)226sys.stdout.write(proc.stderr.decode("utf-8"))227
228if proc.returncode != 0:229# formatting needed, or the command otherwise failed230if args.verbose:231print(f"error: {self.name} exited with code {proc.returncode}")232# Print the diff in the log so that it is viewable there233print(proc.stdout.decode("utf-8"))234return proc.stdout.decode("utf-8")235else:236return None237
238
239class DarkerFormatHelper(FormatHelper):240name = "darker"241friendly_name = "Python code formatter"242
243@property244def instructions(self) -> str:245return " ".join(self.darker_cmd)246
247def filter_changed_files(self, changed_files: List[str]) -> List[str]:248filtered_files = []249for path in changed_files:250name, ext = os.path.splitext(path)251if ext == ".py":252filtered_files.append(path)253
254return filtered_files255
256@property257def darker_fmt_path(self) -> str:258if "DARKER_FORMAT_PATH" in os.environ:259return os.environ["DARKER_FORMAT_PATH"]260return "darker"261
262def has_tool(self) -> bool:263cmd = [self.darker_fmt_path, "--version"]264proc = None265try:266proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)267except:268return False269return proc.returncode == 0270
271def format_run(self, changed_files: List[str], args: FormatArgs) -> Optional[str]:272py_files = self.filter_changed_files(changed_files)273if not py_files:274return None275darker_cmd = [276self.darker_fmt_path,277"--check",278"--diff",279]280if args.start_rev and args.end_rev:281darker_cmd += ["-r", f"{args.start_rev}...{args.end_rev}"]282darker_cmd += py_files283if args.verbose:284print(f"Running: {' '.join(darker_cmd)}")285self.darker_cmd = darker_cmd286proc = subprocess.run(287darker_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE288)289if args.verbose:290sys.stdout.write(proc.stderr.decode("utf-8"))291
292if proc.returncode != 0:293# formatting needed, or the command otherwise failed294if args.verbose:295print(f"error: {self.name} exited with code {proc.returncode}")296# Print the diff in the log so that it is viewable there297print(proc.stdout.decode("utf-8"))298return proc.stdout.decode("utf-8")299else:300sys.stdout.write(proc.stdout.decode("utf-8"))301return None302
303
304ALL_FORMATTERS = (DarkerFormatHelper(), ClangFormatHelper())305
306
307def hook_main():308# fill out args309args = FormatArgs()310args.verbose = False311
312# find the changed files313cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=d"]314proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)315output = proc.stdout.decode("utf-8")316for line in output.splitlines():317args.changed_files.append(line)318
319failed_fmts = []320for fmt in ALL_FORMATTERS:321if fmt.has_tool():322if not fmt.run(args.changed_files, args):323failed_fmts.append(fmt.name)324if fmt.comment:325comments.append(fmt.comment)326else:327print(f"Couldn't find {fmt.name}, can't check " + fmt.friendly_name.lower())328
329if len(failed_fmts) > 0:330sys.exit(1)331
332sys.exit(0)333
334
335if __name__ == "__main__":336script_path = os.path.abspath(__file__)337if ".git/hooks" in script_path:338hook_main()339sys.exit(0)340
341parser = argparse.ArgumentParser()342parser.add_argument(343"--token", type=str, required=True, help="GitHub authentiation token"344)345parser.add_argument(346"--repo",347type=str,348default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"),349help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)",350)351parser.add_argument("--issue-number", type=int, required=True)352parser.add_argument(353"--start-rev",354type=str,355required=True,356help="Compute changes from this revision.",357)358parser.add_argument(359"--end-rev", type=str, required=True, help="Compute changes to this revision"360)361parser.add_argument(362"--changed-files",363type=str,364help="Comma separated list of files that has been changed",365)366parser.add_argument(367"--write-comment-to-file",368action="store_true",369help="Don't post comments on the PR, instead write the comments and metadata a file called 'comment'",370)371
372args = FormatArgs(parser.parse_args())373
374changed_files = []375if args.changed_files:376changed_files = args.changed_files.split(",")377
378failed_formatters = []379comments = []380for fmt in ALL_FORMATTERS:381if not fmt.run(changed_files, args):382failed_formatters.append(fmt.name)383if fmt.comment:384comments.append(fmt.comment)385
386if len(comments):387with open("comments", "w") as f:388import json389
390json.dump(comments, f)391
392if len(failed_formatters) > 0:393print(f"error: some formatters failed: {' '.join(failed_formatters)}")394sys.exit(1)395