| #!/usr/bin/env python |
| |
| import getopt |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| |
| # A path relative to DESIRED_CWD, omitting file extension (assumed to be '.py') |
| RECIPES_TO_BRANCH = ( |
| 'devicelab', |
| 'devicelab/devicelab_drone', |
| 'engine/engine_arm', |
| 'engine/engine_metrics', |
| 'engine/framework_smoke', |
| 'engine/scenarios', |
| 'engine/web_engine_framework', |
| 'engine', |
| 'engine_unopt', |
| 'engine_v2/builder', |
| 'engine_v2/engine_v2', |
| 'engine_v2/tester', |
| 'femu_test', |
| 'firebaselab/firebaselab', |
| 'flutter/android_views', |
| 'flutter/deferred_components', |
| 'flutter/flutter', |
| 'flutter/flutter_drone', |
| 'flutter', |
| 'infra/ci_yaml', |
| 'packages/packages', |
| 'web_engine', |
| ) |
| |
| # These recipes are not used in a Flutter release build, and thus do not need to |
| # be branched. |
| RECIPES_TO_SKIP = ( |
| 'cocoon/cocoon', |
| 'cocoon/device_doctor', |
| 'engine/web_engine_drone', |
| 'engine_builder', |
| 'fuchsia/fuchsia', |
| 'fuchsia_ctl', |
| 'infra/luci_config', |
| 'ios-usb-dependencies', |
| 'plugins/plugins', |
| 'plugins/plugins_publish', |
| 'recipes', |
| 'tricium/tricium', |
| 'infra/test_ownership', |
| ) |
| |
| repo_root = os.path.dirname(os.path.realpath(__file__)) |
| RECIPES_DIR = os.path.join(repo_root, 'recipes') |
| |
| def usage(optional_options, required_options, exit_code=0): |
| """Print script usage and exit.""" |
| print('A command-line tool for generating legacy recipes for release ' + |
| 'branches.\n') |
| print('Usage: ./branch_recipes.py --flutter-version=<flutter version string> ' + |
| '--recipe-revision=<recipes git hash>\n') |
| print('Where --flutter-version is of the form x_y_0 and represents the ' + |
| 'stable release\nthe branch in question is a candidate for, and ' |
| '--recipe-revision is the recipes\nrepo revision at the time the ' |
| 'release branch was branched off of master.\n') |
| print('Required options:') |
| for opt in required_options: |
| print(' --' + opt) |
| print('\nOptional options:') |
| for opt in optional_options: |
| print(' --' + opt) |
| sys.exit(exit_code) |
| |
| def parse_arguments(argv): |
| """Parse and validate command line arguments.""" |
| options = { |
| 'force': False, |
| 'delete': False, |
| } |
| try: |
| optional_options = ('force', 'help', 'delete') |
| required_options = ('flutter-version=', 'recipe-revision=') |
| opts, _args = getopt.getopt(argv, '', optional_options + required_options) |
| except getopt.GetoptError: |
| print('Error parsing arguments!\n') |
| usage(optional_options, required_options, 1) |
| for opt, arg in opts: |
| if opt == '--help': |
| usage(optional_options, required_options, 0) |
| elif opt == '--force': |
| options['force'] = True |
| elif opt == '--flutter-version': |
| if not re.search(r'^\d+_\d+_0+$', arg): |
| print('Error! Invalid value passed to --flutter-version: "%s"' % |
| arg) |
| print('It should be of the form x_y_0') |
| sys.exit(1) |
| options['flutter-version='] = arg |
| elif opt == '--recipe-revision': |
| if not re.search(r'^[0-9a-z]{40}$', arg): |
| print('Error! Invalid value passed to --recipe-revision: "%s"' % |
| arg) |
| print('It should be a valid git hash') |
| sys.exit(1) |
| options['recipe-revision='] = arg |
| elif opt == '--delete': |
| options['delete'] = True |
| # validate |
| if options['delete']: |
| if 'flutter-version=' not in options: |
| usage(optional_options, required_options, 1) |
| else: |
| if 'flutter-version=' not in options or 'recipe-revision=' not in options: |
| usage(optional_options, required_options, 1) |
| return options |
| |
| def get_recipes(working_directory): |
| """Returns the paths to recipes and expectation file directories. |
| |
| Args: |
| working_directory (str): absolute path to the directory where the recipes |
| are located. |
| |
| Returns: |
| latest_recipes (str[]): paths to all unbranched recipes. |
| branched_recipes (str[]): paths to all branched recipes. |
| branched_expectations (str[]): paths to all expectation directories of |
| branches recipes. |
| """ |
| recipe_pattern = r'\.py$' |
| branched_recipe_pattern = r'_\d+_\d+_\d+\.py$' |
| expectation_pattern = r'\.expected$' |
| latest_recipes = [] |
| branched_recipes = [] |
| branched_expectations = [] |
| for root, dirs, files in os.walk(working_directory): |
| for filename in files: |
| if (re.search(recipe_pattern, filename)): |
| if re.search(branched_recipe_pattern, filename): |
| branched_recipes.append(os.path.join(root, filename)) |
| else: |
| latest_recipes.append(os.path.join(root, filename)) |
| for dir_name in dirs: |
| if re.search(expectation_pattern, dir_name): |
| branched_expectations.append(os.path.join(root, dir_name)) |
| return latest_recipes, branched_recipes, branched_expectations |
| |
| def contains(file_path, prefix, tuple_of_candidates): |
| """Given the full path to a file, returns the recipe sub-string. |
| |
| If in the supplied tuple of candidates |
| |
| Args: |
| file_path (str): Path to the file to look up. |
| prefix (str): Absolute path to directory containing all recipes. |
| tuple_of_candidates (str()): Tuple containing expected sub-strings. |
| Return None if file_path does not map to one of these. |
| |
| Returns (str | None): recipe sub-string if the file is contained in the |
| provided candidates, else None. |
| """ |
| for candidate in tuple_of_candidates: |
| if file_path == os.path.join(prefix, candidate + r'.py'): |
| return candidate |
| return None |
| |
| def branch_recipes(options): |
| """Branch all latest recipes on disk.""" |
| latest_recipes, _branched_recipes, _branched_expectations = get_recipes(RECIPES_DIR) |
| for recipe in latest_recipes: |
| recipe_sub_string = contains(recipe, RECIPES_DIR, RECIPES_TO_BRANCH) |
| if recipe_sub_string is not None: |
| print('Reading file %s from revision %s' % (recipe, |
| options['recipe-revision='])) |
| # Hard reset to expected version |
| subprocess.check_output( |
| [ |
| 'git', |
| 'reset', |
| '--hard', |
| options['recipe-revision='] |
| ], |
| cwd=RECIPES_DIR, |
| ) |
| |
| # git show <revision>:path/to/recipe |
| code = subprocess.check_output( |
| [ |
| 'git', |
| 'show', |
| '%s:./%s' % (options['recipe-revision='], recipe_sub_string + r'.py'), |
| ], |
| cwd=RECIPES_DIR, |
| ).decode('utf-8') |
| new_file_path = '%s/%s_%s.py' % (RECIPES_DIR, recipe_sub_string, |
| options['flutter-version=']) |
| if os.path.exists(new_file_path): |
| if options['force']: |
| print('Warning! File %s already exists. About to overwrite...' % |
| new_file_path) |
| else: |
| print('Error! File %s already exists. To overwrite, use the --force flag' |
| % new_file_path) |
| sys.exit(1) |
| with open(new_file_path, 'w') as new_file: |
| print('Writing %s\n' % new_file_path) |
| new_file.write(code) |
| # Copy resources |
| new_resources_dir = '%s/%s_%s.resources' % (RECIPES_DIR, recipe_sub_string, |
| options['flutter-version=']) |
| old_resources_dir = '%s/%s.resources' % (RECIPES_DIR, recipe_sub_string) |
| if os.path.exists(old_resources_dir): |
| if os.path.exists(new_resources_dir): |
| shutil.rmtree(new_resources_dir) |
| shutil.copytree(old_resources_dir, new_resources_dir) |
| else: |
| assert contains(recipe, RECIPES_DIR, RECIPES_TO_SKIP), 'Expected %s to be branched or skipped.' % recipe |
| |
| def delete_recipes(options): |
| """Delete branched recipes and expectation directories of a given version.""" |
| _latest_recipes, branched_recipes, branched_expectations = get_recipes(RECIPES_DIR) |
| branched_recipes.sort() |
| for recipe in branched_recipes: |
| suffix = options['flutter-version='] + r'.py' |
| if recipe.endswith(suffix): |
| print('Deleting file %s' % recipe) |
| os.remove(recipe) |
| for expectations in branched_expectations: |
| suffix = options['flutter-version='] + r'.expected' |
| if expectations.endswith(suffix): |
| print('Deleting directory %s' % expectations) |
| shutil.rmtree(expectations) |
| |
| |
| def main(argv): |
| options = parse_arguments(sys.argv[1:]) |
| |
| if options['delete']: |
| delete_recipes(options) |
| else: |
| branch_recipes(options) |
| |
| main(sys.argv[1:]) |