| #!/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) |