Files
ray-project--ray/ci/pipeline/determine_tests_to_run.py
2026-07-13 13:17:40 +08:00

257 lines
7.6 KiB
Python

#!/usr/bin/env python3
import argparse
import fnmatch
import os
import subprocess
import sys
from pprint import pformat
from typing import List, Optional, Set, Tuple
def _list_changed_files(base: str, commit: str) -> List[str]:
"""Returns a list of names of files changed in the given commit range.
The function works by opening a subprocess and running git. If an error
occurs while running git, the script will abort.
Args:
base: The base branch to diff, fetchable from origin.
commit: The commit to diff.
Returns:
list: List of changed files within the commit range.
"""
if not base:
raise ValueError("Base branch is required.")
if not commit:
raise ValueError("Commit is required.")
pull_command = ["git", "fetch", "-q", "origin", base]
subprocess.check_call(pull_command)
merge_base = (
subprocess.check_output(["git", "merge-base", f"origin/{base}", commit])
.decode()
.strip()
)
command = ["git", "diff", "--name-only", merge_base, commit, "--"]
diff_names = subprocess.check_output(command).decode().strip()
files: List[str] = []
for line in diff_names.splitlines():
line = line.strip()
if line:
files.append(line)
return files
def _is_pull_request():
return os.environ.get("BUILDKITE_PULL_REQUEST", "false") != "false"
def _get_commit_range() -> Tuple[str, str]:
base_branch = os.environ.get("BUILDKITE_PULL_REQUEST_BASE_BRANCH", "master")
head_commit = os.environ.get("BUILDKITE_COMMIT", "HEAD")
return base_branch, head_commit
class TagRule:
def __init__(
self,
tags: List[str],
lineno: int,
dirs: Optional[List[str]] = None,
files: Optional[List[str]] = None,
patterns: Optional[List[str]] = None,
):
self.tags = set(tags)
self.lineno = lineno
self.dirs = dirs or []
self.patterns = patterns or []
self.files = files or []
def match(self, changed_file: str) -> bool:
for dir_name in self.dirs:
if changed_file == dir_name or changed_file.startswith(dir_name + "/"):
return True
for file in self.files:
if changed_file == file:
return True
for pattern in self.patterns:
if fnmatch.fnmatch(changed_file, pattern):
return True
return False
def match_tags(self, changed_file: str) -> Tuple[Set[str], bool]:
if self.match(changed_file):
return self.tags, True
return set(), False
def _parse_rules(rule_content: str) -> Tuple[Set[str], List[TagRule]]:
"""
Parse the rule config content into a list ot TagRule's.
The rule content is a string with the following format:
```
# Comment content, after '#', will be ignored.
# Empty lines will be ignored too.
dir/ # Directory to match
file # File to match
dir/*.py # Pattern to match, using fnmatch, matches dir/a.py dir/dir/b.py or dir/.py
@ tag1 tag2 tag3 # Tags to emit for a rule. A rule without tags is a skipping rule.
; # Semicolon to separate rules
```
Rules are evaluated in order, and the first matched rule will be used.
"""
rules: List[TagRule] = []
tag_defs: Set[str] = set()
tag_defs_ended: bool = False
tags: Set[str] = set()
dirs: List[str] = []
files: List[str] = []
patterns: List[str] = []
lines = rule_content.splitlines()
lineno = 0
for line in lines:
lineno += 1
line = line.strip()
if not line or line.startswith("#"):
continue # Skip empty lines and comments.
comment_index = line.find("#") # Find the first '#' to remove comments.
if comment_index != -1:
line = line[:comment_index].strip() # Remove comments.
if line.startswith("!"):
if tag_defs_ended:
raise ValueError("Tag must be declared at file start.")
tag_defs.update(line[1:].split())
continue
if not tag_defs_ended:
tag_defs_ended = True
if line.startswith("@"): # tags.
# Strip the leading '@' and split into tags.
tags.update(line[1:].split())
elif line.startswith(";"): # End of a rule.
if line != ";":
raise ValueError(f"Unexpected tokens after semicolon on line {lineno}.")
rules.append(TagRule(tags, lineno, dirs, files, patterns))
tags, dirs, files, patterns = set(), [], [], []
else:
if line.find("*") != -1: # Patterns.
patterns.append(line)
elif line.endswith("/"): # Directories.
dirs.append(line[:-1])
else: # Files.
files.append(line)
# Append the last rule if not empty.
if tags or dirs or files or patterns:
rules.append(TagRule(tags, lineno, dirs, files, patterns))
return tag_defs, rules
class TagRuleSet:
def __init__(self, content: Optional[str] = None):
self.tag_defs = set()
self.rules = []
if content is not None:
self.add_rules(content)
def add_rules(self, content: str):
tag_defs, rules = _parse_rules(content)
self.tag_defs.update(tag_defs)
self.rules.extend(rules)
def check_rules(self):
for rule in self.rules:
if not rule.tags:
continue
for tag in rule.tags:
if tag not in self.tag_defs:
raise ValueError(
f"Tag {tag} not declared, used in rule at line {rule.lineno}."
)
def match_tags(self, changed_file: str) -> Tuple[Set[str], bool]:
for rule in self.rules:
match_tags, matched = rule.match_tags(changed_file)
if matched:
return match_tags, True
return set(), False
if __name__ == "__main__":
assert os.environ.get("BUILDKITE")
parser = argparse.ArgumentParser()
parser.add_argument(
"configs", nargs="+", help="Config files that define the rules."
)
args = parser.parse_args()
if len(args.configs) == 0:
raise ValueError("No config files provided.")
rules = TagRuleSet()
for config in args.configs:
with open(config) as f:
rules.add_rules(f.read())
rules.check_rules()
tags: Set[str] = set()
tags.add("always")
tags.add("lint")
def _emit(line: str):
tags.update(line.split())
if _is_pull_request():
base_branch, head_commit = _get_commit_range()
files = _list_changed_files(base_branch, head_commit)
print(pformat(f"origin/{base_branch} -> {head_commit}"), file=sys.stderr)
print(pformat(files), file=sys.stderr)
for changed_file in files:
match_tags, matched = rules.match_tags(changed_file)
if matched:
tags.update(match_tags)
continue
print(
"Unhandled source code change: {changed_file}".format(
changed_file=changed_file
),
file=sys.stderr,
)
_emit("ml tune train data serve core_cpp cpp java core_python doc")
_emit("linux_wheels macos_wheels dashboard tools release_tests")
# Log the modified environment variables visible in console.
output_string = " ".join(list(tags))
for tag in tags:
assert tag in rules.tag_defs, f"Unknown tag {tag}"
print(output_string, file=sys.stderr) # Debug purpose
print(output_string)
else:
print("Run all tags", file=sys.stderr)
print("*")