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