1#!/usr/bin/python3 2# 3# Copyright (C) 2022 The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17import argparse 18import glob 19import json 20import os 21import sys 22 23EXIT_STATUS_OK = 0 24EXIT_STATUS_ERROR = 1 25EXIT_STATUS_NEED_HELP = 2 26 27def FindDirs(path, name, ttl=6): 28 """Search at most ttl directories deep inside path for a directory called name.""" 29 # The dance with subdirs is so that we recurse in sorted order. 30 subdirs = [] 31 with os.scandir(path) as it: 32 for dirent in sorted(it, key=lambda x: x.name): 33 try: 34 if dirent.is_dir(): 35 if dirent.name == name: 36 yield os.path.join(path, dirent.name) 37 elif ttl > 0: 38 subdirs.append(dirent.name) 39 except OSError: 40 # Consume filesystem errors, e.g. too many links, permission etc. 41 pass 42 for subdir in subdirs: 43 yield from FindDirs(os.path.join(path, subdir), name, ttl-1) 44 45 46def WalkPaths(path, matcher, ttl=10): 47 """Do a traversal of all files under path yielding each file that matches 48 matcher.""" 49 # First look for files, then recurse into directories as needed. 50 # The dance with subdirs is so that we recurse in sorted order. 51 subdirs = [] 52 with os.scandir(path) as it: 53 for dirent in sorted(it, key=lambda x: x.name): 54 try: 55 if dirent.is_file(): 56 if matcher(dirent.name): 57 yield os.path.join(path, dirent.name) 58 if dirent.is_dir(): 59 if ttl > 0: 60 subdirs.append(dirent.name) 61 except OSError: 62 # Consume filesystem errors, e.g. too many links, permission etc. 63 pass 64 for subdir in sorted(subdirs): 65 yield from WalkPaths(os.path.join(path, subdir), matcher, ttl-1) 66 67 68def FindFile(path, filename): 69 """Return a file called filename inside path, no more than ttl levels deep. 70 71 Directories are searched alphabetically. 72 """ 73 for f in WalkPaths(path, lambda x: x == filename): 74 return f 75 76 77def FindConfigDirs(workspace_root): 78 """Find the configuration files in the well known locations inside workspace_root 79 80 <workspace_root>/build/orchestrator/multitree_combos 81 (AOSP devices, such as cuttlefish) 82 83 <workspace_root>/vendor/**/multitree_combos 84 (specific to a vendor and not open sourced) 85 86 <workspace_root>/device/**/multitree_combos 87 (specific to a vendor and are open sourced) 88 89 Directories are returned specifically in this order, so that aosp can't be 90 overridden, but vendor overrides device. 91 """ 92 93 # TODO: When orchestrator is in its own git project remove the "make/" here 94 yield os.path.join(workspace_root, "build/make/orchestrator/multitree_combos") 95 96 dirs = ["vendor", "device"] 97 for d in dirs: 98 yield from FindDirs(os.path.join(workspace_root, d), "multitree_combos") 99 100 101def FindNamedConfig(workspace_root, shortname): 102 """Find the config with the given shortname inside workspace_root. 103 104 Config directories are searched in the order described in FindConfigDirs, 105 and inside those directories, alphabetically.""" 106 filename = shortname + ".mcombo" 107 for config_dir in FindConfigDirs(workspace_root): 108 found = FindFile(config_dir, filename) 109 if found: 110 return found 111 return None 112 113 114def ParseProductVariant(s): 115 """Split a PRODUCT-VARIANT name, or return None if it doesn't match that pattern.""" 116 split = s.split("-") 117 if len(split) != 2: 118 return None 119 return split 120 121 122def ChooseConfigFromArgs(workspace_root, args): 123 """Return the config file we should use for the given argument, 124 or null if there's no file that matches that.""" 125 if len(args) == 1: 126 # Prefer PRODUCT-VARIANT syntax so if there happens to be a matching 127 # file we don't match that. 128 pv = ParseProductVariant(args[0]) 129 if pv: 130 config = FindNamedConfig(workspace_root, pv[0]) 131 if config: 132 return (config, pv[1]) 133 return None, None 134 # Look for a specifically named file 135 if os.path.isfile(args[0]): 136 return (args[0], args[1] if len(args) > 1 else None) 137 # That file didn't exist, return that we didn't find it. 138 return None, None 139 140 141class ConfigException(Exception): 142 ERROR_PARSE = "parse" 143 ERROR_CYCLE = "cycle" 144 145 def __init__(self, kind, message, locations, line=0): 146 """Error thrown when loading and parsing configurations. 147 148 Args: 149 message: Error message to display to user 150 locations: List of filenames of the include history. The 0 index one 151 the location where the actual error occurred 152 """ 153 if len(locations): 154 s = locations[0] 155 if line: 156 s += ":" 157 s += str(line) 158 s += ": " 159 else: 160 s = "" 161 s += message 162 if len(locations): 163 for loc in locations[1:]: 164 s += "\n included from %s" % loc 165 super().__init__(s) 166 self.kind = kind 167 self.message = message 168 self.locations = locations 169 self.line = line 170 171 172def LoadConfig(filename): 173 """Load a config, including processing the inherits fields. 174 175 Raises: 176 ConfigException on errors 177 """ 178 def LoadAndMerge(fn, visited): 179 with open(fn) as f: 180 try: 181 contents = json.load(f) 182 except json.decoder.JSONDecodeError as ex: 183 if True: 184 raise ConfigException(ConfigException.ERROR_PARSE, ex.msg, visited, ex.lineno) 185 else: 186 sys.stderr.write("exception %s" % ex.__dict__) 187 raise ex 188 # Merge all the parents into one data, with first-wins policy 189 inherited_data = {} 190 for parent in contents.get("inherits", []): 191 if parent in visited: 192 raise ConfigException(ConfigException.ERROR_CYCLE, "Cycle detected in inherits", 193 visited) 194 DeepMerge(inherited_data, LoadAndMerge(parent, [parent,] + visited)) 195 # Then merge inherited_data into contents, but what's already there will win. 196 DeepMerge(contents, inherited_data) 197 contents.pop("inherits", None) 198 return contents 199 return LoadAndMerge(filename, [filename,]) 200 201 202def DeepMerge(merged, addition): 203 """Merge all fields of addition into merged. Pre-existing fields win.""" 204 for k, v in addition.items(): 205 if k in merged: 206 if isinstance(v, dict) and isinstance(merged[k], dict): 207 DeepMerge(merged[k], v) 208 else: 209 merged[k] = v 210 211 212def Lunch(args): 213 """Handle the lunch command.""" 214 # Check that we're at the top of a multitree workspace 215 # TODO: Choose the right sentinel file 216 if not os.path.exists("build/make/orchestrator"): 217 sys.stderr.write("ERROR: lunch.py must be run from the root of a multi-tree workspace\n") 218 return EXIT_STATUS_ERROR 219 220 # Choose the config file 221 config_file, variant = ChooseConfigFromArgs(".", args) 222 223 if config_file == None: 224 sys.stderr.write("Can't find lunch combo file for: %s\n" % " ".join(args)) 225 return EXIT_STATUS_NEED_HELP 226 if variant == None: 227 sys.stderr.write("Can't find variant for: %s\n" % " ".join(args)) 228 return EXIT_STATUS_NEED_HELP 229 230 # Parse the config file 231 try: 232 config = LoadConfig(config_file) 233 except ConfigException as ex: 234 sys.stderr.write(str(ex)) 235 return EXIT_STATUS_ERROR 236 237 # Fail if the lunchable bit isn't set, because this isn't a usable config 238 if not config.get("lunchable", False): 239 sys.stderr.write("%s: Lunch config file (or inherited files) does not have the 'lunchable'" 240 % config_file) 241 sys.stderr.write(" flag set, which means it is probably not a complete lunch spec.\n") 242 243 # All the validation has passed, so print the name of the file and the variant 244 sys.stdout.write("%s\n" % config_file) 245 sys.stdout.write("%s\n" % variant) 246 247 return EXIT_STATUS_OK 248 249 250def FindAllComboFiles(workspace_root): 251 """Find all .mcombo files in the prescribed locations in the tree.""" 252 for dir in FindConfigDirs(workspace_root): 253 for file in WalkPaths(dir, lambda x: x.endswith(".mcombo")): 254 yield file 255 256 257def IsFileLunchable(config_file): 258 """Parse config_file, flatten the inheritance, and return whether it can be 259 used as a lunch target.""" 260 try: 261 config = LoadConfig(config_file) 262 except ConfigException as ex: 263 sys.stderr.write("%s" % ex) 264 return False 265 return config.get("lunchable", False) 266 267 268def FindAllLunchable(workspace_root): 269 """Find all mcombo files in the tree (rooted at workspace_root) that when 270 parsed (and inheritance is flattened) have lunchable: true.""" 271 for f in [x for x in FindAllComboFiles(workspace_root) if IsFileLunchable(x)]: 272 yield f 273 274 275def List(): 276 """Handle the --list command.""" 277 for f in sorted(FindAllLunchable(".")): 278 print(f) 279 280 281def Print(args): 282 """Handle the --print command.""" 283 # Parse args 284 if len(args) == 0: 285 config_file = os.environ.get("TARGET_BUILD_COMBO") 286 if not config_file: 287 sys.stderr.write("TARGET_BUILD_COMBO not set. Run lunch or pass a combo file.\n") 288 return EXIT_STATUS_NEED_HELP 289 elif len(args) == 1: 290 config_file = args[0] 291 else: 292 return EXIT_STATUS_NEED_HELP 293 294 # Parse the config file 295 try: 296 config = LoadConfig(config_file) 297 except ConfigException as ex: 298 sys.stderr.write(str(ex)) 299 return EXIT_STATUS_ERROR 300 301 # Print the config in json form 302 json.dump(config, sys.stdout, indent=4) 303 304 return EXIT_STATUS_OK 305 306 307def main(argv): 308 if len(argv) < 2 or argv[1] == "-h" or argv[1] == "--help": 309 return EXIT_STATUS_NEED_HELP 310 311 if len(argv) == 2 and argv[1] == "--list": 312 List() 313 return EXIT_STATUS_OK 314 315 if len(argv) == 2 and argv[1] == "--print": 316 return Print(argv[2:]) 317 return EXIT_STATUS_OK 318 319 if (len(argv) == 2 or len(argv) == 3) and argv[1] == "--lunch": 320 return Lunch(argv[2:]) 321 322 sys.stderr.write("Unknown lunch command: %s\n" % " ".join(argv[1:])) 323 return EXIT_STATUS_NEED_HELP 324 325if __name__ == "__main__": 326 sys.exit(main(sys.argv)) 327 328 329# vim: sts=4:ts=4:sw=4 330