blob: 1ba421325ddadb17b689a2233cb2c0090bac02c2 [file]
#!/usr/bin/env python3
# Copyright 2024 The Emscripten Authors. All rights reserved.
# Emscripten is available under two separate licenses, the MIT license and the
# University of Illinois/NCSA Open Source License. Both these licenses can be
# found in the LICENSE file.
"""Automatically rebaseline tests that have codesize expectations.
This script can also create a git commit containing the resulting changes
along with some details of the changes.
"""
import argparse
import json
import os
import re
import statistics
import subprocess
import sys
import urllib.request
script_dir = os.path.dirname(os.path.abspath(__file__))
root_dir = os.path.dirname(os.path.dirname(script_dir))
sys.path.insert(0, root_dir)
from tools import building, shared, utils
def run(cmd, **args):
return subprocess.check_output(cmd, text=True, cwd=root_dir, **args)
all_deltas: list[int] = []
def read_size_from_json(content):
json_data = json.loads(content)
if 'total' in json_data:
return json_data['total']
# If `total` if not in the json dict then just use the first key. This happens when only one
# file size is reported (in this case we don't calculate or store the `total`).
first_key = list(json_data.keys())[0]
return json_data[first_key]
def get_installed_emsdk_sha():
emsdk = os.environ.get('EMSDK', os.path.join(os.path.dirname(root_dir), 'emsdk'))
version_file = os.path.join(emsdk, 'upstream', '.emsdk_version')
if not os.path.exists(version_file):
return None
version = utils.read_file(version_file).strip()
parts = version.split('-')
if len(parts) < 2 or len(parts[1]) != 40:
utils.exit_with_error(f'failed to parse .emsdk_version: {version}')
return parts[1]
def format_emsdk_version_update(old_sha, new_sha, limit=30):
assert old_sha != 'none'
assert old_sha != new_sha
log_url = f'https://chromium.googlesource.com/emscripten-releases/+log/{old_sha}..{new_sha}'
url = f'{log_url}?format=JSON&n={limit + 1}'
data = urllib.request.urlopen(url, timeout=5).read().decode('utf-8')
if data.startswith(")]}'"):
data = data[4:]
j = json.loads(data)
entries = j.get('log', [])
lines = [f'emsdk version updated: {old_sha} => {new_sha}']
lines.append('')
lines.append('The following revisions were included in this update:')
lines.append('')
lines.append('```')
for entry in entries[:limit]:
short_sha = entry['commit'][:8]
subject = entry['message'].splitlines()[0]
lines.append(f'- {short_sha} {subject}')
if len(entries) > limit:
lines.append(' ... (and more, see full log)')
lines.append('```')
lines.append("")
lines.append(f"Full log: {log_url}")
return '\n'.join(lines)
def process_changed_file(filename):
content = utils.read_file(filename)
old_content = run(['git', 'show', f'HEAD:{filename}'])
print(f'processing {filename}')
ext = os.path.splitext(filename)[1]
if ext == '.size':
size = int(content.strip())
old_size = int(old_content.strip())
elif ext == '.json':
size = read_size_from_json(content)
old_size = read_size_from_json(old_content)
else:
# Unhandled file type
return f'{filename} updated\n'
filename = filename.removeprefix('test/')
delta = size - old_size
percent_delta = delta * 100 / old_size
all_deltas.append(percent_delta)
return f'{filename}: {old_size} => {size} [{delta:+} bytes / {percent_delta:+.2f}%]\n'
def patch_file(file_path, pattern, replacement):
"""Patch a file using regular expressions."""
content = utils.read_file(file_path)
new_content, count = re.subn(pattern, replacement, content, flags=re.MULTILINE)
if count != 1:
utils.exit_with_error(f'failed to patch {file_path} (pattern: `{pattern}`, got {count} matches, expected 1)')
utils.write_file(file_path, new_content)
def update_expected_llvm_version():
clang_version_str = shared.get_clang_version()
if not clang_version_str:
utils.exit_with_error('--update-emsdk specified but could not detect clang version')
new_version = int(clang_version_str.split('.')[0])
if new_version == shared.EXPECTED_LLVM_VERSION:
return None
print(f'update EXPECTED_LLVM_VERSION: {shared.EXPECTED_LLVM_VERSION} => {new_version}')
shared_py_file = os.path.join(root_dir, 'tools', 'shared.py')
patch_file(shared_py_file, r'^EXPECTED_LLVM_VERSION = \d+$', f'EXPECTED_LLVM_VERSION = {new_version}')
return f'Expected LLVM version updated: {shared.EXPECTED_LLVM_VERSION} => {new_version}'
def update_expected_binaryen_version():
bindir = building.get_binaryen_bin()
binaryen_version_str = building.get_binaryen_version(bindir)
if not binaryen_version_str:
utils.exit_with_error('--update-emsdk specified but could not detect binaryen version')
try:
new_version = int(binaryen_version_str.splitlines()[0].split()[2])
except (IndexError, ValueError):
utils.exit_with_error(f'error parsing binaryen version ({binaryen_version_str})')
if new_version == building.EXPECTED_BINARYEN_VERSION:
return None
print(f'update EXPECTED_BINARYEN_VERSION: {building.EXPECTED_BINARYEN_VERSION} => {new_version}')
building_py_file = os.path.join(root_dir, 'tools', 'building.py')
patch_file(building_py_file, r'^EXPECTED_BINARYEN_VERSION = \d+$', f'EXPECTED_BINARYEN_VERSION = {new_version}')
return f'Expected Binaryen version updated: {building.EXPECTED_BINARYEN_VERSION} => {new_version}'
def main():
parser = argparse.ArgumentParser()
parser.add_argument('-s', '--skip-tests', action='store_true', help="Don't actually run the tests, just analyze the existing results")
parser.add_argument('-b', '--new-branch', action='store_true', help='Create a new branch containing the updates')
parser.add_argument('-c', '--clear-cache', action='store_true', help='Clear the cache before rebaselining (useful when working with llvm changes)')
parser.add_argument('-n', '--check-only', dest='check_only', action='store_true', help='Return non-zero if test expectations are out of date, and skip creating a git commit')
parser.add_argument('--update-emsdk', action='store_true', help='Update test/emsdk_version.txt and expected tool versions to match the currently installed emsdk version')
args = parser.parse_args()
if args.clear_cache:
run(['./emcc', '--clear-cache'])
current_sha = None
installed_sha = None
tool_updates = []
if not args.skip_tests:
if not args.check_only and run(['git', 'status', '-uno', '--porcelain']).strip():
print('tree is not clean')
return 1
installed_sha = get_installed_emsdk_sha()
emsdk_version_file = os.path.join(root_dir, 'test', 'emsdk_version.txt')
current_sha = utils.read_file(emsdk_version_file).strip()
if args.update_emsdk:
if not installed_sha:
utils.exit_with_error('--update-emsdk specified but could not detect installed emsdk version')
if installed_sha == current_sha:
print(f'emsdk version is up-to-date ({current_sha})')
return 0
print(f'update emsdk: {current_sha} => {installed_sha}')
utils.write_file(emsdk_version_file, installed_sha + '\n')
if update := update_expected_llvm_version():
tool_updates.append(update)
if update := update_expected_binaryen_version():
tool_updates.append(update)
elif installed_sha and installed_sha != current_sha:
utils.exit_with_error(f'installed emsdk version ({installed_sha}) does not match test/emsdk_version.txt ({current_sha}). Pass --update-emsdk to update it.')
if installed_sha:
installed_sha = installed_sha[:8]
current_sha = current_sha[:8]
subprocess.check_call([utils.exe_path_from_root('test/runner'), '--rebaseline', 'codesize'], cwd=root_dir)
output = run(['git', 'status', '-uno', '--porcelain'])
filenames = []
for line in output.splitlines():
filename = line.strip().rsplit(' ', 1)[1]
if filename.startswith('test') and os.path.isfile(filename) and filename != 'test/emsdk_version.txt':
filenames.append(filename)
if not filenames and not args.update_emsdk:
print('test expectations are up-to-date')
return 0
if args.check_only:
message = 'Test expectations are out-of-date\n'
elif args.update_emsdk:
message = f'''Update emsdk version from {current_sha} to {installed_sha}
This is an automatic change generated by tools/maint/rebaseline_tests.py --update-emsdk.
'''
if tool_updates:
message += '\n'.join(tool_updates) + '\n\n'
message += format_emsdk_version_update(current_sha, installed_sha) + '\n'
else:
message = '''Automatic rebaseline of codesize expectations. NFC
This is an automatic change generated by tools/maint/rebaseline_tests.py.
'''
if filenames:
message += f'''
The following ({len(filenames)}) test expectation files were updated by
running the tests with `--rebaseline`:
```
'''
for file in sorted(filenames):
message += process_changed_file(file)
if all_deltas:
message += f'\nAverage change: {statistics.mean(all_deltas):+.2f}% ({min(all_deltas):+.2f}% - {max(all_deltas):+.2f}%)\n'
message += '```\n'
print(message)
if args.check_only:
return 1
if args.new_branch:
branch_name = 'update_emsdk' if args.update_emsdk else 'rebaseline_tests'
run(['git', 'checkout', '-b', branch_name])
run(['git', 'add', '-u', '.'])
run(['git', 'commit', '-F', '-'], input=message)
return 2
if __name__ == '__main__':
sys.exit(main())