Ian Vollick | 00424e5 | 2023-02-03 10:35:50 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
| 2 | # Copyright 2023 The Chromium Authors |
| 3 | # Use of this source code is governed by a BSD-style license that can be |
| 4 | # found in the LICENSE file. |
| 5 | """ |
| 6 | Updates .filelist files using data from corresponding .globlist files (or |
| 7 | checks whether they are up to date). |
| 8 | |
| 9 | bundle_data targets require an explicit source list, but maintaining these large |
| 10 | lists can be cumbersome. This script aims to simplify the process of updating |
| 11 | these lists by either expanding globs to update file lists or check that an |
| 12 | existing file list matches such an expansion (i.e., checking during presubmit). |
| 13 | |
| 14 | The .globlist file contains a list of globs that will be expanded to either |
| 15 | compare or replace a corresponding .filelist. It is possible to exclude items |
| 16 | from the file list with globs as well. These lines are prefixed with '-' and are |
| 17 | processed in order, so be sure that exclusions succeed inclusions in the list of |
| 18 | globs. Comments and empty lines are permitted in .globfiles; comments are |
| 19 | prefixed with '#'. |
| 20 | |
| 21 | By convention, the base name of the .globlist and .filelist files matches the |
| 22 | label of their corresponding bundle_data from the .gn file. In order to ensure |
| 23 | that these filelists don't get stale, there should also be a PRESUBMIT.py |
| 24 | which uses this script to check that list is up to date. |
| 25 | |
| 26 | By default, the script will update the file list to match the expanded globs. |
| 27 | """ |
| 28 | |
| 29 | import argparse |
| 30 | import datetime |
| 31 | import difflib |
| 32 | import glob |
| 33 | import os.path |
| 34 | import re |
| 35 | import sys |
| 36 | |
| 37 | # Character to set colors in terminal. Taken, along with the printing routine |
| 38 | # below, from update_deps.py. |
| 39 | TERMINAL_ERROR_COLOR = '\033[91m' |
| 40 | TERMINAL_RESET_COLOR = '\033[0m' |
| 41 | |
| 42 | _HEADER = """# Copyright %d The Chromium Authors |
| 43 | # Use of this source code is governed by a BSD-style license that can be |
| 44 | # found in the LICENSE file. |
| 45 | # NOTE: this file is generated by build/ios/update_bundle_filelist.py |
| 46 | # If it requires updating, you should get a presubmit error with |
| 47 | # instructions on how to regenerate. Otherwise, do not edit. |
| 48 | """ % (datetime.datetime.now().year) |
| 49 | |
| 50 | _HEADER_PATTERN = re.compile(r"""# Copyright [0-9]+ The Chromium Authors |
| 51 | # Use of this source code is governed by a BSD-style license that can be |
| 52 | # found in the LICENSE file. |
| 53 | # NOTE: this file is generated by build/ios/update_bundle_filelist.py |
| 54 | # If it requires updating, you should get a presubmit error with |
| 55 | # instructions on how to regenerate. Otherwise, do not edit. |
| 56 | """) |
| 57 | |
| 58 | _HEADER_HEIGHT = 6 |
| 59 | |
| 60 | |
| 61 | def parse_filelist(filelist_name): |
| 62 | try: |
| 63 | with open(filelist_name) as filelist: |
| 64 | unfiltered = [l for l in filelist] |
| 65 | header = ''.join(unfiltered[:_HEADER_HEIGHT]) |
| 66 | files = sorted(l.strip() for l in unfiltered[_HEADER_HEIGHT:]) |
| 67 | return (files, header) |
| 68 | except Exception as e: |
| 69 | print_error(f'Could not read file list: {filelist_name}', f'{type(e)}: {e}') |
| 70 | return [] |
| 71 | |
| 72 | |
| 73 | def parse_and_expand_globlist(globlist_name, glob_root): |
| 74 | try: |
| 75 | # The following expects glob_root not to end in a trailing slash. |
| 76 | if glob_root.endswith('/'): |
| 77 | glob_root = glob_root[:-1] |
| 78 | |
| 79 | with open(globlist_name) as globlist: |
| 80 | files = [] |
| 81 | for g in globlist: |
| 82 | g = g.strip() |
| 83 | |
| 84 | # Ignore blank lines and comments. |
| 85 | if not g or g.startswith('#'): |
| 86 | continue |
| 87 | |
| 88 | # Exclusions are prefixed with '-'. |
| 89 | is_exclusion = g.startswith('-') |
| 90 | if is_exclusion: |
| 91 | g = g[1:] |
| 92 | |
| 93 | prefix_size = len(glob_root) |
| 94 | full_glob = '' |
| 95 | |
| 96 | if g.startswith('//'): |
| 97 | # If globlist is relative to the repository root, os.path |
| 98 | # will consider it absolute and os.path.join will fail. |
| 99 | # In this case, we can simply concatenate the paths. |
| 100 | full_glob = glob_root + g |
| 101 | else: |
| 102 | full_glob = os.path.join(glob_root, g) |
| 103 | # We need to account for the separator. |
| 104 | prefix_size += 1 |
| 105 | |
| 106 | expansion = glob.glob(full_glob, recursive=True) |
| 107 | |
| 108 | # Filter out directories. |
| 109 | expansion = [f for f in expansion if os.path.isfile(f)] |
| 110 | |
| 111 | # Make relative to |glob_root|. |
| 112 | expansion = [f[prefix_size:] for f in expansion] |
| 113 | |
| 114 | if is_exclusion: |
Bruce Dawson | 29955b0 | 2023-02-04 00:03:48 | [diff] [blame^] | 115 | files = [f for f in files if f.replace('\\', '/') not in expansion] |
Ian Vollick | 00424e5 | 2023-02-03 10:35:50 | [diff] [blame] | 116 | else: |
| 117 | files += expansion |
| 118 | |
Bruce Dawson | 29955b0 | 2023-02-04 00:03:48 | [diff] [blame^] | 119 | # Handle Windows backslashes |
| 120 | files = [f.replace('\\', '/') for f in files] |
Ian Vollick | 00424e5 | 2023-02-03 10:35:50 | [diff] [blame] | 121 | files.sort() |
| 122 | return files |
| 123 | |
| 124 | except Exception as e: |
| 125 | print_error(f'Could not read glob list: {globlist_name}', f'{type(e)}: {e}') |
| 126 | return [] |
| 127 | |
| 128 | |
| 129 | def compare_lists(a, b, verbose): |
| 130 | differ = difflib.Differ() |
| 131 | full_diff = differ.compare(a, b) |
| 132 | diff = '\n'.join([d for d in full_diff if not d.startswith(' ')]) |
| 133 | if diff: |
| 134 | if verbose: |
| 135 | print_error('File list does not match glob expansion', f'{diff}') |
| 136 | return False |
| 137 | return True |
| 138 | |
| 139 | |
| 140 | def write_filelist(filelist_name, files, header): |
| 141 | try: |
Bruce Dawson | 29955b0 | 2023-02-04 00:03:48 | [diff] [blame^] | 142 | with open(filelist_name, 'w', encoding='utf-8', newline='') as filelist: |
Ian Vollick | 00424e5 | 2023-02-03 10:35:50 | [diff] [blame] | 143 | if not _HEADER_PATTERN.search(header): |
| 144 | header = _HEADER |
| 145 | filelist.write(header) |
| 146 | for file in files: |
| 147 | filelist.write(f'{file}\n') |
| 148 | except Exception as e: |
| 149 | print_error(f'Could not write file list: {filelist_name}', |
| 150 | f'{type(e)}: {e}') |
| 151 | return [] |
| 152 | |
| 153 | |
| 154 | def process_filelist(filelist, globlist, globroot, check=False, verbose=False): |
| 155 | files_from_globlist = parse_and_expand_globlist(globlist, globroot) |
| 156 | (files, header) = parse_filelist(filelist) |
| 157 | if check: |
| 158 | if not _HEADER_PATTERN.search(header): |
| 159 | if verbose: |
| 160 | print_error(f'Unexpected header for {filelist}', f'{header}') |
| 161 | return 1 |
| 162 | if compare_lists(files, files_from_globlist, verbose): |
| 163 | return 0 |
| 164 | return 1 |
| 165 | else: |
| 166 | write_filelist(filelist, files_from_globlist, header) |
| 167 | return 0 |
| 168 | |
| 169 | |
| 170 | def main(args): |
| 171 | parser = argparse.ArgumentParser( |
| 172 | description=__doc__, formatter_class=argparse.RawTextHelpFormatter) |
| 173 | parser.add_argument('filelist', help='Contains one file per line') |
| 174 | parser.add_argument('globlist', |
| 175 | help='Contains globs that, when expanded, ' |
| 176 | 'should match the filelist. Use ' |
| 177 | '--help for details on syntax') |
| 178 | parser.add_argument('globroot', |
| 179 | help='Directory from which globs are relative') |
| 180 | parser.add_argument('-c', |
| 181 | '--check', |
| 182 | action='store_true', |
| 183 | help='Prevents modifying the file list') |
| 184 | parser.add_argument('-v', |
| 185 | '--verbose', |
| 186 | action='store_true', |
| 187 | help='Use this to print details on differences') |
| 188 | args = parser.parse_args() |
| 189 | return process_filelist(args.filelist, |
| 190 | args.globlist, |
| 191 | args.globroot, |
| 192 | check=args.check, |
| 193 | verbose=args.verbose) |
| 194 | |
| 195 | |
| 196 | def print_error(error_message, error_info): |
| 197 | """ Print the `error_message` with additional `error_info` """ |
| 198 | color_start, color_end = adapted_color_for_output(TERMINAL_ERROR_COLOR, |
| 199 | TERMINAL_RESET_COLOR) |
| 200 | |
| 201 | error_message = color_start + 'ERROR: ' + error_message + color_end |
| 202 | if len(error_info) > 0: |
| 203 | error_message = error_message + '\n' + error_info |
| 204 | print(error_message, file=sys.stderr) |
| 205 | |
| 206 | |
| 207 | def adapted_color_for_output(color_start, color_end): |
| 208 | """ Returns a the `color_start`, `color_end` tuple if the output is a |
| 209 | terminal, or empty strings otherwise """ |
| 210 | if not sys.stdout.isatty(): |
| 211 | return '', '' |
| 212 | return color_start, color_end |
| 213 | |
| 214 | |
| 215 | if __name__ == '__main__': |
| 216 | sys.exit(main(sys.argv[1:])) |