blob: 35dac7334132c157674187373d680e7040a7863a [file] [log] [blame]
#!/usr/bin/python3
#
# Copyright (C) 2022 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import argparse
import glob
import json
import os
import sys
EXIT_STATUS_OK = 0
EXIT_STATUS_ERROR = 1
EXIT_STATUS_NEED_HELP = 2
def FindDirs(path, name, ttl=6):
"""Search at most ttl directories deep inside path for a directory called name."""
# The dance with subdirs is so that we recurse in sorted order.
subdirs = []
with os.scandir(path) as it:
for dirent in sorted(it, key=lambda x: x.name):
try:
if dirent.is_dir():
if dirent.name == name:
yield os.path.join(path, dirent.name)
elif ttl > 0:
subdirs.append(dirent.name)
except OSError:
# Consume filesystem errors, e.g. too many links, permission etc.
pass
for subdir in subdirs:
yield from FindDirs(os.path.join(path, subdir), name, ttl-1)
def WalkPaths(path, matcher, ttl=10):
"""Do a traversal of all files under path yielding each file that matches
matcher."""
# First look for files, then recurse into directories as needed.
# The dance with subdirs is so that we recurse in sorted order.
subdirs = []
with os.scandir(path) as it:
for dirent in sorted(it, key=lambda x: x.name):
try:
if dirent.is_file():
if matcher(dirent.name):
yield os.path.join(path, dirent.name)
if dirent.is_dir():
if ttl > 0:
subdirs.append(dirent.name)
except OSError:
# Consume filesystem errors, e.g. too many links, permission etc.
pass
for subdir in sorted(subdirs):
yield from WalkPaths(os.path.join(path, subdir), matcher, ttl-1)
def FindFile(path, filename):
"""Return a file called filename inside path, no more than ttl levels deep.
Directories are searched alphabetically.
"""
for f in WalkPaths(path, lambda x: x == filename):
return f
def FindConfigDirs(workspace_root):
"""Find the configuration files in the well known locations inside workspace_root
<workspace_root>/build/orchestrator/multitree_combos
(AOSP devices, such as cuttlefish)
<workspace_root>/vendor/**/multitree_combos
(specific to a vendor and not open sourced)
<workspace_root>/device/**/multitree_combos
(specific to a vendor and are open sourced)
Directories are returned specifically in this order, so that aosp can't be
overridden, but vendor overrides device.
"""
# TODO: When orchestrator is in its own git project remove the "make/" here
yield os.path.join(workspace_root, "build/make/orchestrator/multitree_combos")
dirs = ["vendor", "device"]
for d in dirs:
yield from FindDirs(os.path.join(workspace_root, d), "multitree_combos")
def FindNamedConfig(workspace_root, shortname):
"""Find the config with the given shortname inside workspace_root.
Config directories are searched in the order described in FindConfigDirs,
and inside those directories, alphabetically."""
filename = shortname + ".mcombo"
for config_dir in FindConfigDirs(workspace_root):
found = FindFile(config_dir, filename)
if found:
return found
return None
def ParseProductVariant(s):
"""Split a PRODUCT-VARIANT name, or return None if it doesn't match that pattern."""
split = s.split("-")
if len(split) != 2:
return None
return split
def ChooseConfigFromArgs(workspace_root, args):
"""Return the config file we should use for the given argument,
or null if there's no file that matches that."""
if len(args) == 1:
# Prefer PRODUCT-VARIANT syntax so if there happens to be a matching
# file we don't match that.
pv = ParseProductVariant(args[0])
if pv:
config = FindNamedConfig(workspace_root, pv[0])
if config:
return (config, pv[1])
return None, None
# Look for a specifically named file
if os.path.isfile(args[0]):
return (args[0], args[1] if len(args) > 1 else None)
# That file didn't exist, return that we didn't find it.
return None, None
class ConfigException(Exception):
ERROR_PARSE = "parse"
ERROR_CYCLE = "cycle"
def __init__(self, kind, message, locations, line=0):
"""Error thrown when loading and parsing configurations.
Args:
message: Error message to display to user
locations: List of filenames of the include history. The 0 index one
the location where the actual error occurred
"""
if len(locations):
s = locations[0]
if line:
s += ":"
s += str(line)
s += ": "
else:
s = ""
s += message
if len(locations):
for loc in locations[1:]:
s += "\n included from %s" % loc
super().__init__(s)
self.kind = kind
self.message = message
self.locations = locations
self.line = line
def LoadConfig(filename):
"""Load a config, including processing the inherits fields.
Raises:
ConfigException on errors
"""
def LoadAndMerge(fn, visited):
with open(fn) as f:
try:
contents = json.load(f)
except json.decoder.JSONDecodeError as ex:
if True:
raise ConfigException(ConfigException.ERROR_PARSE, ex.msg, visited, ex.lineno)
else:
sys.stderr.write("exception %s" % ex.__dict__)
raise ex
# Merge all the parents into one data, with first-wins policy
inherited_data = {}
for parent in contents.get("inherits", []):
if parent in visited:
raise ConfigException(ConfigException.ERROR_CYCLE, "Cycle detected in inherits",
visited)
DeepMerge(inherited_data, LoadAndMerge(parent, [parent,] + visited))
# Then merge inherited_data into contents, but what's already there will win.
DeepMerge(contents, inherited_data)
contents.pop("inherits", None)
return contents
return LoadAndMerge(filename, [filename,])
def DeepMerge(merged, addition):
"""Merge all fields of addition into merged. Pre-existing fields win."""
for k, v in addition.items():
if k in merged:
if isinstance(v, dict) and isinstance(merged[k], dict):
DeepMerge(merged[k], v)
else:
merged[k] = v
def Lunch(args):
"""Handle the lunch command."""
# Check that we're at the top of a multitree workspace
# TODO: Choose the right sentinel file
if not os.path.exists("build/make/orchestrator"):
sys.stderr.write("ERROR: lunch.py must be run from the root of a multi-tree workspace\n")
return EXIT_STATUS_ERROR
# Choose the config file
config_file, variant = ChooseConfigFromArgs(".", args)
if config_file == None:
sys.stderr.write("Can't find lunch combo file for: %s\n" % " ".join(args))
return EXIT_STATUS_NEED_HELP
if variant == None:
sys.stderr.write("Can't find variant for: %s\n" % " ".join(args))
return EXIT_STATUS_NEED_HELP
# Parse the config file
try:
config = LoadConfig(config_file)
except ConfigException as ex:
sys.stderr.write(str(ex))
return EXIT_STATUS_ERROR
# Fail if the lunchable bit isn't set, because this isn't a usable config
if not config.get("lunchable", False):
sys.stderr.write("%s: Lunch config file (or inherited files) does not have the 'lunchable'"
% config_file)
sys.stderr.write(" flag set, which means it is probably not a complete lunch spec.\n")
# All the validation has passed, so print the name of the file and the variant
sys.stdout.write("%s\n" % config_file)
sys.stdout.write("%s\n" % variant)
return EXIT_STATUS_OK
def FindAllComboFiles(workspace_root):
"""Find all .mcombo files in the prescribed locations in the tree."""
for dir in FindConfigDirs(workspace_root):
for file in WalkPaths(dir, lambda x: x.endswith(".mcombo")):
yield file
def IsFileLunchable(config_file):
"""Parse config_file, flatten the inheritance, and return whether it can be
used as a lunch target."""
try:
config = LoadConfig(config_file)
except ConfigException as ex:
sys.stderr.write("%s" % ex)
return False
return config.get("lunchable", False)
def FindAllLunchable(workspace_root):
"""Find all mcombo files in the tree (rooted at workspace_root) that when
parsed (and inheritance is flattened) have lunchable: true."""
for f in [x for x in FindAllComboFiles(workspace_root) if IsFileLunchable(x)]:
yield f
def List():
"""Handle the --list command."""
for f in sorted(FindAllLunchable(".")):
print(f)
def Print(args):
"""Handle the --print command."""
# Parse args
if len(args) == 0:
config_file = os.environ.get("TARGET_BUILD_COMBO")
if not config_file:
sys.stderr.write("TARGET_BUILD_COMBO not set. Run lunch or pass a combo file.\n")
return EXIT_STATUS_NEED_HELP
elif len(args) == 1:
config_file = args[0]
else:
return EXIT_STATUS_NEED_HELP
# Parse the config file
try:
config = LoadConfig(config_file)
except ConfigException as ex:
sys.stderr.write(str(ex))
return EXIT_STATUS_ERROR
# Print the config in json form
json.dump(config, sys.stdout, indent=4)
return EXIT_STATUS_OK
def main(argv):
if len(argv) < 2 or argv[1] == "-h" or argv[1] == "--help":
return EXIT_STATUS_NEED_HELP
if len(argv) == 2 and argv[1] == "--list":
List()
return EXIT_STATUS_OK
if len(argv) == 2 and argv[1] == "--print":
return Print(argv[2:])
return EXIT_STATUS_OK
if (len(argv) == 2 or len(argv) == 3) and argv[1] == "--lunch":
return Lunch(argv[2:])
sys.stderr.write("Unknown lunch command: %s\n" % " ".join(argv[1:]))
return EXIT_STATUS_NEED_HELP
if __name__ == "__main__":
sys.exit(main(sys.argv))
# vim: sts=4:ts=4:sw=4