• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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