blob: 5632abbba6019a79391c6079649683b4ded53d98 [file]
# Copyright 2021 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Simple script for cleaning up stale configs from fieldtrial_testing_config.
Methodology:
Scan for all study names that appear in fieldtrial config file,
and removes ones that don't appear anywhere in the codebase.
First rule out studies that are exempted from removal. Then for each source
file in scope, look for traces of every study (by their names or involved
feature names) using regular expressions. Matches studies are retained,
studies that fail to be found anywhere are removed.
The script ignores WebRTC entries as those often lead to false positives.
Usage:
vpython3 tools/variations/cleanup_stale_fieldtrial_configs.py
Run with --help to get a complete list of options this script runs with.
If this script removes features that appear to be used in the codebase,
double-check the study or feature name for typos or case differences.
"""
from __future__ import print_function
import collections
import json
import optparse
import os
import re
import subprocess
import sys
import threading
CONFIG_PATH = 'testing/variations/fieldtrial_testing_config.json'
PRESUBMIT_SCRIPT = 'testing/variations/PRESUBMIT.py'
THREAD_COUNT = 16
# The following is a list of regexes to run against literals, and if matched,
# the literal would be counted as being used. Use this to skip removal of
# studies (and studies that depend on features) that are not visible in code.
# Eg. ChromeOS where experiments are passed from ash to platform services.
_LITERAL_SKIP_REGEX_STRINGS = [
'^CrOSLateBoot.*',
'^CrOSEarlyBoot.*',
'^V8Flag_.*',
'^WebRTC-.*',
]
_LITERAL_SKIP_REGEXES = [
re.compile(regexp_str) for regexp_str in _LITERAL_SKIP_REGEX_STRINGS
]
_DECLARE_FEATURE_PATTERNS = [
# Basic form: BASE_FEATURE(kMyFeature...)
re.compile(r'BASE_FEATURE\s*\([^)]*\bk(\w+)[^)]*\)'),
# Extra pattern for net/dns
re.compile(r'MAKE_BASE_FEATURE_WITH_STATIC_STORAGE\s*\([^)]*\bk(\w+)[^)]*\)'
),
# Quoted old-style form: BASE_FEATURE(.., "My-Feature",...)
re.compile(r'BASE_FEATURE\s*\([^)]*"([\w-]+)"[^)]*\)'),
# Forward declaration: BASE_DECLARE_FEATURE(kMyFeature...)
re.compile(r'BASE_DECLARE_FEATURE\s*\([^)]*\bk(\w+)[^)]*\)'),
# Java syntax: Flag.baseFeature("My-Feature")
re.compile(r'Flag\.baseFeature\s*\([^)]*"([\w-]+)"[^)]*\)'),
]
def is_literal_in_skiplist(literal: str) -> bool:
"""Returns true if the literal is in the skiplist."""
for regex in _LITERAL_SKIP_REGEXES:
if regex.match(literal):
print('Skipping', repr(literal), 'due to', regex)
return True
return False
def read_literals(studies: dict[str, list[dict]]) -> dict[str, set[str]]:
"""Returns the literals from the studies, organized per study."""
literals = {}
for study, configs in studies.items():
literals[study] = {study}
for config in configs:
for experiment in config.get('experiments', []):
literals[study].update(experiment.get('enable_features', []))
literals[study].update(experiment.get('disable_features', []))
return literals
def skip_exempted_studies(literals: dict[str, set[str]]) -> set[str]:
"""Skips exempted studies."""
found_studies = set()
for study, literals in literals.items():
for literal in literals:
if is_literal_in_skiplist(literal):
found_studies.add(study)
break
return found_studies
def thread_func(thread_limiter: threading.BoundedSemaphore, file_name: str,
literal_to_study: dict[str, set[str]],
found_studies: set[str]) -> None:
"""Runs a task that scans a file for the presence of studies.
Essentially for given file this function performs regular expression matching.
For each match, it extracts the actual matched literal and looks up studies
that are referred to by that literal. Found study is marked by moving to the
`found_studies` set data structure.
Args:
thread_limiter: Thread limiter to limit number of threads used.
file_name: File name to scan for studies.
studies: Studies to scan for.
found_studies: Set of studies that have been found.
"""
thread_limiter.acquire()
try:
with open(file_name, 'r', encoding='utf-8', errors='ignore') as file:
content = file.read()
for pattern in _DECLARE_FEATURE_PATTERNS:
for match in pattern.finditer(content):
found_literal = match.group(1)
for study in literal_to_study.get(found_literal, []):
found_studies.add(study)
print(f'Found study {study} by {pattern.pattern} for literal '
f'{found_literal} in {file_name}. '
f'Confirmed studies: {len(found_studies)}')
finally:
thread_limiter.release()
def find_studies_by_literals(all_files: bytes,
literals_by_study: dict[str, set[str]],
found_studies: set[str],
thread_limiter: threading.BoundedSemaphore):
"""Finds studies by literals.
For each file in files, iterates over literals and checks if the literal is
in the file. If literal matches, the study name is copied to found_studies.
Args:
all_files: All files to scan for studies.
literals_by_study: Maps study to literals that are relevant to it.
found_studies: Set of studies that have been found.
thread_limiter: Thread limiter to limit number of threads used.
"""
literal_to_study = invert_studies(literals_by_study)
threads = []
for file_name in all_files.splitlines():
args = (thread_limiter, file_name, literal_to_study, found_studies)
threads.append(threading.Thread(target=thread_func, args=args))
# Start all threads, then join all threads.
for t in threads:
t.start()
for t in threads:
t.join()
def sort_find_output(find_output: bytes) -> bytes:
"""Sort output from find command.
Args:
find_output: Output from find command.
Returns:
Sorted output from find command. Files with names that make it more likely
they are relevant to fieldtrials are first.
"""
all_files = find_output.splitlines()
# False comes before True when sorting, so reverse=True puts the preferred
# filename first.
key_re = re.compile(rb'feature|switch|flag')
all_files.sort(key=lambda x: key_re.search(x) is not None, reverse=True)
# Revert splitlines() to recreate the original byte-output stream.
return b'\n'.join(all_files)
def invert_studies(studies: dict[str, set[str]]) -> dict[str, set[str]]:
"""Inverts the studies dict with literals to a literal to studies mapping."""
literal_to_study = collections.defaultdict(set)
for study, literals in studies.items():
for literal in literals:
literal_to_study[literal].add(study)
return literal_to_study
def main():
parser = optparse.OptionParser()
parser.add_option('--input_path',
help='Path to the fieldtrial config file to clean.')
parser.add_option('--output_path',
help='Path to write cleaned up fieldtrial config file.')
parser.add_option('--thread_count',
type='int',
help='The number of threads to use for scanning.')
opts, _ = parser.parse_args()
input_path = os.path.expanduser(opts.input_path or CONFIG_PATH)
output_path = os.path.expanduser(opts.output_path or CONFIG_PATH)
thread_limiter = threading.BoundedSemaphore(opts.thread_count or THREAD_COUNT)
with open(input_path) as fin:
studies = json.load(fin)
print('Loaded config from', input_path)
# Use single find command to get all files to scan for studies.
command = ['find', '.', '-type', 'f']
# Ignore self-referential files and test files.
command.extend(['-not', '-name', 'fieldtrial_testing_config.*'])
command.extend(['-not', '-name', '*test.cc'])
command.extend(['-not', '-name', '*test.mm'])
command.extend(['-not', '-name', '*Test.java'])
# Pick headers, objective-c and c++ modules.
command.extend([
'(', '-name', '*.h', '-o', '-name', '*.cc', '-o', '-name', '*.mm', '-o',
'-name', '*.java', ')'
])
# All relevant source files.
all_files = sort_find_output(
subprocess.Popen(command,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL).stdout.read())
print(f'Working with {len(all_files.splitlines())} source files')
# Feature and study name literals, per study.
literals_by_study = read_literals(studies)
print(f'Collected {len(literals_by_study)} studies')
found_studies = set()
# Stage one: do not process specific studies.
found_studies |= skip_exempted_studies(literals_by_study)
# Stage two: find studies that have any related literal present
# in the codebase.
find_studies_by_literals(all_files, literals_by_study, found_studies,
thread_limiter)
# Stage three: copy over studies that are found.
clean_studies = dict()
for study in found_studies:
clean_studies[study] = studies[study]
with open(output_path, 'wt') as fout:
json.dump(clean_studies, fout)
print('Wrote cleaned config to', output_path)
# Run presubmit script to format config file.
retcode = subprocess.call(['vpython3', PRESUBMIT_SCRIPT, output_path])
if retcode != 0:
print('Failed to format output, manually run:')
print('vpython3', PRESUBMIT_SCRIPT, output_path)
if __name__ == '__main__':
sys.exit(main())