blob: 62a498dd0a71feb20db9cbe0098d9185e90dfef1 [file] [log] [blame] [edit]
#!/usr/bin/env python3
#
# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
import argparse
import os
import subprocess
import sys
from typing import List, Optional
"""
This script is run by GitHub actions to ensure that the code in PRs properly
labels LLVM APIs with `LLVM_ABI` so as not to break the LLVM DLL build. It can
also be installed as a pre-commit git hook to check ABI annotations before
submitting. The canonical source of this script is in the LLVM source tree
under llvm/utils/git.
This script uses the idt (Interface Diff Tool) to check for missing LLVM_ABI,
LLVM_C_ABI, and DEMANGLE_ABI annotations in header files.
You can install this script as a git hook by symlinking it to the .git/hooks
directory:
ln -s $(pwd)/llvm/utils/git/ids-check-helper.py .git/hooks/pre-commit
You can control the exact path to idt and compile_commands.json with the
following environment variables: $IDT_PATH and $COMPILE_COMMANDS_PATH.
"""
class IdsCheckArgs:
start_rev: str = ""
end_rev: str = ""
changed_files: List[str] = []
idt_path: str = ""
compile_commands: str = ""
repo: str = ""
token: str = ""
issue_number: int = 0
verbose: bool = True
def __init__(self, args: argparse.Namespace) -> None:
self.start_rev = args.start_rev
self.end_rev = args.end_rev
self.changed_files = args.changed_files
self.idt_path = args.idt_path
self.compile_commands = args.compile_commands
self.repo = getattr(args, "repo", "")
self.token = getattr(args, "token", "")
self.issue_number = getattr(args, "issue_number", 0)
self.verbose = getattr(args, "verbose", True)
class IdsChecker:
"""
Checker for LLVM ABI annotations using the idt tool.
"""
COMMENT_TAG = "<!--LLVM IDS CHECK COMMENT-->"
name = "ids-check"
friendly_name = "LLVM ABI annotation checker"
comment: dict = {}
# Macro definition used for all export macros
MACRO_DEFINITION = '__attribute__((visibility("default")))'
@property
def comment_tag(self) -> str:
return self.COMMENT_TAG
@property
def instructions(self) -> str:
# Provide basic usage instructions
return f"""git diff origin/main HEAD -- 'llvm/include/llvm/**/*.h' 'llvm/include/llvm-c/**/*.h' 'llvm/include/llvm/Demangle/**/*.h'
Then run idt on the changed files with appropriate --export-macro and --include-header flags."""
def pr_comment_text_for_diff(self, diff: str) -> str:
return f"""
:warning: {self.friendly_name}, {self.name} found issues in your code. :warning:
<details>
<summary>
You can test this locally with the following command:
</summary>
``````````bash
{self.instructions}
``````````
:warning:
The reproduction instructions above might return results for more than one PR
in a stack if you are using a stacked PR workflow. You can limit the results by
changing `origin/main` to the base branch/commit you want to compare against.
:warning:
</details>
<details>
<summary>
View the diff from {self.name} here.
</summary>
``````````diff
{diff}
``````````
</details>
"""
def update_pr(
self, comment_text: str, args: IdsCheckArgs, create_new: bool
) -> None:
import github
repo = github.Github(auth=github.Auth.Token(args.token)).get_repo(args.repo)
pr = repo.get_issue(args.issue_number).as_pull_request()
comment_text = self.comment_tag + "\n\n" + comment_text
existing_comment = None
for comment in pr.as_issue().get_comments():
if self.comment_tag in comment.body:
existing_comment = comment
break
if existing_comment:
self.comment = {"body": comment_text, "id": existing_comment.id}
elif create_new:
self.comment = {"body": comment_text}
# Define the file categories and their corresponding configurations
FILE_CATEGORIES = [
{
"name": "LLVM headers",
"patterns": ["llvm/include/llvm/**/*.h"],
"excludes": [
"llvm/include/llvm/Debuginfod/",
"llvm/include/llvm/Demangle/",
],
"export_macro": "LLVM_ABI",
"include_header": "llvm/include/llvm/Support/Compiler.h",
},
{
"name": "LLVM-C headers",
"patterns": ["llvm/include/llvm-c/**/*.h"],
"excludes": [],
"export_macro": "LLVM_C_ABI",
"include_header": "llvm/include/llvm-c/Visibility.h",
},
{
"name": "LLVM Demangle headers",
"patterns": ["llvm/include/llvm/Demangle/**/*.h"],
"excludes": [],
"export_macro": "DEMANGLE_ABI",
"include_header": "llvm/Demangle/Visibility.h",
},
]
def filter_files_for_category(
self, changed_files: List[str], category: dict
) -> List[str]:
"""Filter changed files based on category patterns and excludes."""
filtered = []
for path in changed_files:
# Check if file matches any pattern
matches_pattern = False
for pattern in category["patterns"]:
# Simple pattern matching for /**/*.h style patterns
pattern_prefix = pattern.replace("/**/*.h", "")
if path.startswith(pattern_prefix) and path.endswith(".h"):
matches_pattern = True
break
if not matches_pattern:
continue
# Check if file should be excluded
excluded = False
for exclude in category["excludes"]:
if path.startswith(exclude):
excluded = True
break
if not excluded:
filtered.append(path)
return filtered
def run_idt_on_files(
self,
files: List[str],
category: dict,
args: IdsCheckArgs,
idt_path: str,
compile_commands: str,
) -> bool:
"""Run idt tool on the given files with category-specific configuration."""
if not files:
return True
if args.verbose:
print(
f"Running idt on {len(files)} {category['name']} file(s)...",
file=sys.stderr,
)
for file in files:
cmd = [
idt_path,
"-p",
compile_commands,
"--apply-fixits",
"--inplace",
f"--export-macro={category['export_macro']}",
f"--include-header={category['include_header']}",
f"--extra-arg=-D{category['export_macro']}={self.MACRO_DEFINITION}",
"--extra-arg=-Wno-macro-redefined",
file,
]
if args.verbose:
print(f"Running: {' '.join(cmd)}", file=sys.stderr)
subprocess.run(cmd)
return True
def get_changed_files(self, args: IdsCheckArgs) -> List[str]:
"""Get list of changed files between revisions."""
if args.changed_files:
return args.changed_files
cmd = ["git", "diff", "--name-only", args.start_rev, args.end_rev]
if args.verbose:
print(f"Running: {' '.join(cmd)}", file=sys.stderr)
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
print("Error: Failed to get changed files", file=sys.stderr)
sys.stderr.write(proc.stderr.decode("utf-8"))
return []
files = proc.stdout.decode("utf-8").strip().split("\n")
return [f for f in files if f]
def check_for_diff(self) -> Optional[str]:
"""Check if there are any uncommitted changes after running idt."""
cmd = ["git", "diff"]
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
diff = proc.stdout.decode("utf-8")
if diff:
return diff
return None
def run(self, args: IdsCheckArgs) -> int:
"""Main entry point for running ids check."""
# Resolve idt path: prefer command line arg, then env var
idt_path = args.idt_path or os.environ.get("IDT_PATH")
if not idt_path:
print(
"Error: idt path not specified. Use --idt-path argument or set IDT_PATH environment variable",
file=sys.stderr,
)
return 1
if not os.path.exists(idt_path):
print(f"Error: idt tool not found at {idt_path}", file=sys.stderr)
return 1
# Resolve compile_commands path: prefer command line arg, then env var
compile_commands = args.compile_commands or os.environ.get(
"COMPILE_COMMANDS_PATH"
)
if not compile_commands:
print(
"Error: compile_commands.json path not specified. Use --compile-commands argument or set COMPILE_COMMANDS_PATH environment variable",
file=sys.stderr,
)
return 1
if not os.path.exists(compile_commands):
print(
f"Error: compile_commands.json not found at {compile_commands}",
file=sys.stderr,
)
return 1
# Get changed files
changed_files = self.get_changed_files(args)
if not changed_files:
if args.verbose:
print("No files changed, skipping ids check", file=sys.stderr)
return 0
# Process each category
any_processed = False
for category in self.FILE_CATEGORIES:
filtered_files = self.filter_files_for_category(changed_files, category)
if filtered_files:
any_processed = True
if not self.run_idt_on_files(
filtered_files, category, args, idt_path, compile_commands
):
return 1
if not any_processed:
if args.verbose:
print(
"No relevant header files changed, skipping ids check",
file=sys.stderr,
)
return 0
# Check for differences
diff = self.check_for_diff()
should_update_gh = args.token is not None and args.repo is not None
if diff:
if should_update_gh:
comment_text = self.pr_comment_text_for_diff(diff)
self.update_pr(comment_text, args, create_new=True)
else:
print(
"\nError: idt found missing LLVM_ABI annotations", file=sys.stderr
)
print(
"Apply the following diff to fix the LLVM_ABI annotations:\n",
file=sys.stderr,
)
print(diff)
return 1
else:
if should_update_gh:
comment_text = (
":white_check_mark: With the latest revision "
f"this PR passed the {self.friendly_name}."
)
self.update_pr(comment_text, args, create_new=False)
if args.verbose:
print("All files pass ids check", file=sys.stderr)
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Check LLVM ABI annotations in header files"
)
parser.add_argument("--token", type=str, help="GitHub authentication token")
parser.add_argument(
"--repo",
type=str,
default=os.getenv("GITHUB_REPOSITORY", "llvm/llvm-project"),
help="The GitHub repository that we are working with in the form of <owner>/<repo> (e.g. llvm/llvm-project)",
)
parser.add_argument("--issue-number", type=int, help="GitHub issue/PR number")
parser.add_argument(
"--start-rev",
type=str,
required=True,
help="Compute changes from this revision",
)
parser.add_argument(
"--end-rev",
type=str,
required=True,
help="Compute changes to this revision",
)
parser.add_argument(
"--changed-files",
type=str,
help="Comma separated list of files that have been changed",
)
parser.add_argument(
"--idt-path",
type=str,
help="Path to the idt executable (can also be set via IDT_PATH environment variable)",
)
parser.add_argument(
"--compile-commands",
type=str,
help="Path to compile_commands.json (can also be set via COMPILE_COMMANDS_PATH environment variable)",
)
parser.add_argument(
"--verbose", action="store_true", default=True, help="Enable verbose output"
)
parsed_args = parser.parse_args()
# Parse changed files if provided
if parsed_args.changed_files:
parsed_args.changed_files = [
f.strip() for f in parsed_args.changed_files.split(",") if f.strip()
]
else:
parsed_args.changed_files = []
args = IdsCheckArgs(parsed_args)
checker = IdsChecker()
exit_code = checker.run(args)
if checker.comment:
with open("comments", "w") as f:
import json
json.dump([checker.comment], f)
sys.exit(exit_code)