blob: 97793231f294091c724fd608270ebf86e1107b17 [file]
#!/usr/bin/env python3
# Copyright 2024 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
# ----------------------------------------------------------------------------
# A temporary script to evaluate how many patches produced by the tool compile.
# The output are stored on the Google internal spreadsheet:
#
# ----------------------------------------------------------------------------
# To install the required dependencies to interact with the Google Sheets API:
# ```
# python3 -m venv env
# source env/bin/activate
# pip install --upgrade \
# google-api-python-client \
# google-auth-httplib2 \
# google-auth-oauthlib
#
# In addition when invoking evaluate_patches.py you can pass an optional integer
# argument which will set the limit of patches to evaluate. Default is 100.
# ```
from datetime import datetime
import argparse
import getpass
import os
import pathlib
import random
import re
import subprocess
import sys
from spanify_utils import scratch_dir
def strip_ansi(text):
ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
return ansi_escape.sub('', text)
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from googleapiclient.http import MediaFileUpload
# common gn args for spanify project scripts.
from gnconfigs import GnConfigs, GenerateGnTarget
from project import PROJECTS
GOOGLE_DRIVE_FOLDER_ID = '18lLW_YPCXRGUYiPghDXTA6h2TC4WpGeJ'
def run(command, error_message=None, exit_on_error=True, cwd=None):
"""
Helper function to run a shell command.
"""
print('-' * 80)
if sys.stdout.isatty():
print(f"\033[7m{command}\033[0m")
else:
print(command)
try:
output = subprocess.run(
command, shell=True, check=True, text=True, cwd=cwd
)
except subprocess.CalledProcessError as e:
print(
error_message
if error_message
else "Failed to run command: `" + command + "`",
file=sys.stderr,
)
if exit_on_error:
raise e
return False
return True
def get_current_branch(cwd=None):
"""
Returns the current branch name or revision.
"""
# --abbrev-ref HEAD returns the branch name or "HEAD"
result = subprocess.run(
"git rev-parse --abbrev-ref HEAD",
shell=True,
check=True,
text=True,
capture_output=True,
cwd=cwd,
)
branch = result.stdout.strip()
if branch != "HEAD":
return branch
result = subprocess.run(
"git rev-parse HEAD",
shell=True,
check=True,
text=True,
capture_output=True,
cwd=cwd,
)
return result.stdout.strip()
def restore(cwd):
"""
Restores the staged and unstaged working tree changes of the repository.
"""
run(
"git restore --staged .", "Failed to restore staged repository", cwd=cwd
)
run("git restore .", "Failed to restore repository", cwd=cwd)
def analyze_error(stdout, stderr):
"""
Analyzes compiler output to extract a clean, readable error message.
"""
# Errors format: <file>:<line>:<column>: [fatal] error: <error_msg>
error_regex = re.compile(r"([^:]+:\d+(?::\d+)?: (?:fatal )?error: .*)")
for line in stdout.split("\n") + stderr.split("\n"):
match = error_regex.search(line)
if match:
return match.group(1)
# Fallback to Siso/Ninja FAILED line
failed_regex = re.compile(r"FAILED: .*")
for line in stdout.split("\n") + stderr.split("\n"):
match = failed_regex.search(line)
if match:
return match.group(0)
# Generic fallback: grab the first non-empty, non-utility line of output
for line in (stderr + "\n" + stdout).split("\n"):
if (
line.strip()
and not line.startswith("ninja: Entering directory")
and not line.startswith("shutdown cloud logging")
):
return line.strip()
return "Unknown compilation error"
def get_google_creds():
"""
Returns creds to both google spreadsheet and drive, potentially asking for
it to be allowed.
"""
creds = None
SCOPES = [
'https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive',
]
token_path = 'token.json'
if not os.path.exists(token_path):
token_path = os.path.expanduser('~/token.json')
if os.path.exists(token_path):
creds = Credentials.from_authorized_user_file(token_path, SCOPES)
# If there are no (valid) credentials available, let the user log in.
if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
creds_path = 'credentials.json'
if not os.path.exists(creds_path):
creds_path = os.path.expanduser('~/credentials.json')
flow = InstalledAppFlow.from_client_secrets_file(creds_path, SCOPES)
creds = flow.run_local_server(port=1234)
# Save the token to the path we found it or the local directory.
with open(token_path, 'w') as token:
token.write(creds.to_json())
return creds
def get_spreadsheet(creds):
"""
Returns a Google spreadsheet object to interact with the Autospan tracker.
"""
spreadsheet = (
build('sheets', 'v4', credentials=creds).spreadsheets().values()
)
return spreadsheet
def append_row(spreadsheet, spreadsheet_id, values):
"""
Appends a row to the Google spreadsheet.
It performs some basic truncation and other logic to prevent easy to make
errors (like to much text in a single cell).
"""
# Google spreadsheet enforces a maximum 50000 chars so we truncate the data
# down to fit to prevent the error.
values = list(
map(lambda val: val[0:50000] if len(str(val)) > 50000 else val, values)
)
try:
spreadsheet.append(
spreadsheetId=spreadsheet_id,
range='Data!A1:D',
body={"majorDimension": "ROWS", "values": [values]},
valueInputOption="USER_ENTERED",
).execute(num_retries=5)
except HttpError as err:
print(f"append_row failed: {err}", file=sys.stderr)
def upload_zip_to_drive_folder(creds, zip_file):
"""
uploads the provided zip_file to the autospan folder for easy storage and
inspection of patches as needed.
This folder can be found at:
https://drive.google.com/drive/folders/18llw_ypcxrguyipghdxta6h2tc4wpgej
"""
try:
service = build("drive", "v3", credentials=creds)
name = os.path.basename(zip_file)
file_metadata = {"name": name, "parents": [GOOGLE_DRIVE_FOLDER_ID]}
media = MediaFileUpload(zip_file, mimetype="application/zip")
# pylint: disable=maybe-no-member
file = (
service.files()
.create(body=file_metadata, media_body=media, fields="id")
.execute()
)
print(
f"uploaded {zip_file} as {name} "
f"to file: https://drive.google.com/file/d/{file.get('id')}"
)
except HttpError as error:
print(f"Uploading to drive errored: {error}", file=sys.stderr)
def upload_scratch(creds, file_name):
"""
Uploads a new file to the provided google drive folder.
"""
try:
# Since we've reduced the size of stdout we can include everything in
# the scratch directory.
output_zip = scratch_dir() / file_name
run(f'zip -q "{output_zip}" "{scratch_dir()}"/*')
upload_zip_to_drive_folder(creds, output_zip)
except Exception as e:
print(f"Failed to upload scratch: {e}", file=sys.stderr)
def report_case_result(
result,
spreadsheet,
spreadsheet_id,
today,
index,
total_patches,
user,
error_msg,
diff,
final_file,
):
with (scratch_dir() / "evaluation.csv").open("a") as f:
f.write(f"{index}, {result}, {error_msg}\n")
try:
append_row(
spreadsheet,
spreadsheet_id,
[
today,
index,
total_patches,
result,
error_msg,
diff,
user,
],
)
except Exception as e:
try:
append_row(
spreadsheet,
spreadsheet_id,
[
today,
index,
total_patches,
result,
f"\"Failed to upload to spreadsheet: {str(e)}\"",
f"diff_len: {len(diff)} error_msg_len: {len(error_msg)}",
user,
],
)
except Exception as err:
print(
f"Failed to append_row for simplified data spreadsheet: {err}",
file=sys.stderr,
)
print(
f"Failed to append_row but uploaded error to spreadsheet: {e}",
file=sys.stderr,
)
with (scratch_dir() / f"patch_{index}.{result}").open("w+") as f:
f.write(final_file)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Evaluate patches produced by the spanify tool."
)
parser.add_argument(
"--patch-limit",
type=int,
default=100,
help="Limit of patches to evaluate.",
)
parser.add_argument(
"platform",
nargs="?",
default="linux",
help="Platform to evaluate the patches on.",
)
parser.add_argument(
"--project",
choices=PROJECTS.keys(),
default="chrome",
help="Project to evaluate.",
)
args = parser.parse_args()
root_dir = pathlib.Path(__file__).resolve().parents[3]
os.environ['CHROMIUM_BUILDTOOLS_PATH'] = str(root_dir / 'buildtools')
platform = args.platform
patch_limit = args.patch_limit
project = args.project
spreadsheet_id = PROJECTS[project]['spreadsheet_id']
rewrite_project = project
submodule = PROJECTS[project].get('submodule', '.')
standalone = submodule != '.'
run_gn_check = PROJECTS[project].get('run_gn_check', True)
print(f"Running evaluate_patches.py for project {project}...")
# Produce a full rewrite, and store individual patches below
# <scratch>/patch_*
run(
f"./tools/clang/spanify/rewrite_multiple_platforms.py \
--platform={platform} \
--project={rewrite_project} \
"
)
# Record the starting branch/revision to restore it at the end.
original_top_branch = get_current_branch()
original_submodule_branch = get_current_branch(submodule)
today = datetime.now().strftime("%Y/%m/%d")
today_underscore = today.replace("/", "_")
creds = get_google_creds()
spreadsheet = get_spreadsheet(creds)
user = getpass.getuser()
def report_success(index, total_patches, error_msg, diff, final_file):
report_case_result(
"pass",
spreadsheet,
spreadsheet_id,
today,
index,
total_patches,
user,
error_msg,
diff,
final_file,
)
def report_failure(index, total_patches, error_msg, diff, final_file):
report_case_result(
"fail",
spreadsheet,
spreadsheet_id,
today,
index,
total_patches,
user,
error_msg,
diff,
final_file,
)
cwd = submodule if standalone else None
# Generate build files
configs = GnConfigs(False)
gn_args = configs.get_config(platform, project)
if gn_args is None:
print(
f"Error: Unknown platform/project combination: {platform}/{project}"
)
sys.exit(1)
try:
run("gcertstatus --check_remaining=3h --nocheck_ssh")
if project in ['chrome', 'partition_alloc']:
print("Remote exec available. Enabling.")
gn_args = GnConfigs(True).get_config(platform, project) or gn_args
else:
print(
"Submodule project. Keeping remote exec disabled to avoid"
" ACL issues."
)
gn_path = root_dir / 'buildtools/linux64/gn'
# Ensure the build directory exists and write GN args directly to
# args.gn
# to avoid shell escaping and quoting issues.
out_dir = (
pathlib.Path(cwd) if cwd else pathlib.Path(".")
).resolve() / f"out/{platform}"
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "args.gn").write_text('\n'.join(gn_args))
# We manually run gn gen to ensure it's in the right directory.
run(
f"{gn_path} gen out/{platform}",
f"Failed to generate out/{platform}.",
cwd=cwd,
)
except Exception as e:
print(f"Remote exec not available or failed: {e}. Disabling.")
gn_args = GnConfigs(False).get_config(platform, project)
if gn_args is None:
print(
f"Error: Unknown platform/project combination: "
f"{platform}/{project}"
)
sys.exit(1)
gn_path = root_dir / 'buildtools/linux64/gn'
# Ensure the build directory exists and write GN args directly to
# args.gn
# to avoid shell escaping and quoting issues.
out_dir = (
pathlib.Path(cwd) if cwd else pathlib.Path(".")
).resolve() / f"out/{platform}"
out_dir.mkdir(parents=True, exist_ok=True)
(out_dir / "args.gn").write_text('\n'.join(gn_args))
run(
f"{gn_path} gen out/{platform}",
f"Failed to generate out/{platform}.",
cwd=cwd,
)
run(
f'git rev-parse HEAD > "{(scratch_dir() / "git_revision.txt")}"',
cwd=submodule,
)
# Restore the workspace to a clean state, excluding the spanify tools
# directory.
restore(cwd)
patches = [p.name for p in scratch_dir().glob("patch_*.txt")]
# Sort numerically the patches to evaluate.
patches.sort(key=lambda x: int(x.split("_")[1].split(".")[0]))
total_patches_count = len(patches)
patches = patches[:patch_limit]
print(
f"Found {total_patches_count} patches to evaluate "
f"(limiting to {len(patches)})."
)
# This file will store a summary of each patch evaluation.
# ```
# patch, status, error_msg
# 0, pass, ""
# 1, fail, "subcommand failed"
# 2, pass, ""
# 3, pass, ""
# 4, fail, "subcommand failed"
# ```
with (scratch_dir() / "evaluation.csv").open("w+") as f:
f.write("patch, status, error_msg\n")
# Perform a clean build to ensure a valid state for the incremental builds.
run(
f"autoninja -C out/{platform}",
"Failed to build the project. Ensure the output directory and targets"
" are correct.",
exit_on_error=True,
cwd=cwd,
)
# Create and evaluate patches
try:
for patch in patches:
# Extract the patch index from the patch name string:
# patch_{index}.txt
index = int(patch.split("_")[1].split(".")[0])
print(f"Producing patch {index}/{len(patches)}: {patch}")
diff = ""
final_file = ""
try:
# Apply edits
apply_edits_path = os.path.abspath(
"tools/clang/scripts/apply_edits.py"
)
cmd = [
sys.executable,
apply_edits_path,
"-p",
f"out/{platform}",
]
try:
with open(scratch_dir() / patch, "rb") as patch_f:
result = subprocess.run(
cmd,
stdin=patch_f,
capture_output=True,
text=True,
cwd=cwd,
check=True,
)
except subprocess.CalledProcessError as e:
stdout_clean = strip_ansi(e.stdout)
stderr_clean = strip_ansi(e.stderr)
error_msg = (
"\""
+ str(e)
+ " !!! exception(stderr): "
+ stderr_clean
+ "\""
)
diff_path = scratch_dir() / f"patch_{index}.diff"
run(
f'git diff > "{diff_path}"',
cwd=cwd,
)
diff = (scratch_dir() / f"patch_{index}.diff").read_text()
final_file = stderr_clean + "\n" + stdout_clean
report_failure(
index, total_patches_count, error_msg, diff, final_file
)
continue
run("git cl format", cwd=cwd, exit_on_error=False)
# Serialize changes (Generate diff directly from working tree)
run(
f'git diff > "{(scratch_dir() / f"patch_{index}.diff")}"',
cwd=cwd,
)
diff = (scratch_dir() / f"patch_{index}.diff").read_text()
if not diff.strip():
# We fail when there is no diff, get the replacements
# instead
diff = (scratch_dir() / f"patch_{index}.txt").read_text()
final_file = (
strip_ansi(result.stderr)
+ "\n"
+ strip_ansi(result.stdout)
)
report_failure(
index,
total_patches_count,
"No files modified by patch",
diff,
final_file,
)
continue
# Build and evaluate
print(f"Evaluating patch {index}/{len(patches)}")
print("Building...")
result = subprocess.run(
f"time autoninja -C out/{platform}",
shell=True,
capture_output=True,
text=True,
cwd=cwd,
)
stdout_clean = strip_ansi(result.stdout)
stderr_clean = strip_ansi(result.stderr)
final_file = stderr_clean + "\n" + stdout_clean
with (scratch_dir() / f"patch_{index}.out").open("w+") as f:
f.write(stderr_clean)
f.write(stdout_clean)
if result.returncode != 0:
error_msg = analyze_error(stdout_clean, stderr_clean)
report_failure(
index, total_patches_count, error_msg, diff, final_file
)
elif run_gn_check and not run(
f'gn check out/{platform}', exit_on_error=False, cwd=cwd
):
error_msg = "failed gn check"
report_failure(
index, total_patches_count, error_msg, diff, final_file
)
else:
report_success(
index, total_patches_count, "", diff, final_file
)
finally:
# Unconditionally restore the workspace to clean for the
# next patch
restore(cwd)
finally:
# Restore the starting branch/revision.
run(
f"git checkout {original_submodule_branch}",
exit_on_error=False,
cwd=submodule,
)
run(f"git checkout {original_top_branch}", exit_on_error=False)
# Regardless of success or failure we want to upload the scratch
# directory to the shared google drive for easy debugging of either
# compile errors or
# the evaluate_patches tool itself.
unique_id = random.randint(1, 10000)
file_name = (
f"{today_underscore}_evaluate_patches_{user}_{unique_id}.zip"
)
upload_scratch(creds, file_name)