1#!/usr/bin/env 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"""Utility functions to produce module or module type dependency graphs using json-module-graph or queryview."""
17
18import collections
19import dataclasses
20import json
21import os
22import os.path
23import subprocess
24import sys
25from typing import Dict, Optional, Set
26import xml.etree.ElementTree
27from bp2build_metrics_proto.bp2build_metrics_pb2 import Bp2BuildMetrics
28
29
30@dataclasses.dataclass(frozen=True, order=True)
31class TargetProduct:
32  product: Optional[str] = None
33  banchan_mode: bool = False
34
35
36@dataclasses.dataclass(frozen=True, order=True)
37class _ModuleKey:
38  """_ModuleKey uniquely identifies a module by name nad variations."""
39
40  name: str
41  variations: list
42
43  def __str__(self):
44    return f"{self.name}, {self.variations}"
45
46  def __hash__(self):
47    return (self.name + str(self.variations)).__hash__()
48
49
50# This list of module types are omitted from the report and graph
51# for brevity and simplicity. Presence in this list doesn't mean
52# that they shouldn't be converted, but that they are not that useful
53# to be recorded in the graph or report currently.
54IGNORED_KINDS = set([
55    "cc_defaults",
56    "cpython3_python_stdlib",
57    "hidl_package_root",  # not being converted, contents converted as part of hidl_interface
58    "java_defaults",
59    "license",
60    "license_kind",
61])
62
63# queryview doesn't have information on the type of deps, so we explicitly skip
64# prebuilt types
65_QUERYVIEW_IGNORE_KINDS = set([
66    "android_app_import",
67    "android_library_import",
68    "cc_prebuilt_library",
69    "cc_prebuilt_library_headers",
70    "cc_prebuilt_library_shared",
71    "cc_prebuilt_library_static",
72    "cc_prebuilt_library_static",
73    "cc_prebuilt_object",
74    "java_import",
75    "java_import_host",
76    "java_sdk_library_import",
77    "cpython3_python_stdlib",
78    "cpython2_python_stdlib",
79])
80
81
82# Soong adds some dependencies that are handled by Bazel as part of the
83# toolchain
84_TOOLCHAIN_DEP_TYPES = frozenset([
85    "python.dependencyTag {BaseDependencyTag:{} name:hostLauncher}",
86    "python.dependencyTag {BaseDependencyTag:{} name:hostLauncherSharedLib}",
87    "python.dependencyTag {BaseDependencyTag:{} name:hostStdLib}",
88    "python.dependencyTag {BaseDependencyTag:{} name:launcher}",
89    (
90        "python.installDependencyTag {BaseDependencyTag:{}"
91        " InstallAlwaysNeededDependencyTag:{} name:launcherSharedLib}"
92    ),
93])
94
95def get_src_root_dir() -> str:
96  # Search up the directory tree until we find soong_ui.bash as a regular file, not a symlink.
97  # This is so that we find the real source tree root, and not the bazel execroot which symlimks in
98  # soong_ui.bash.
99  def soong_ui(path):
100    return os.path.join(path, 'build/soong/soong_ui.bash')
101
102  path = '.'
103  while not os.path.isfile(soong_ui(path)) or os.path.islink(soong_ui(path)):
104    if os.path.abspath(path) == '/':
105      sys.exit('Could not find android source tree root.')
106    path = os.path.join(path, '..')
107  return os.path.abspath(path)
108
109SRC_ROOT_DIR = get_src_root_dir()
110
111LUNCH_ENV = {
112    # Use aosp_arm as the canonical target product.
113    "TARGET_PRODUCT": "aosp_arm",
114    "TARGET_BUILD_VARIANT": "userdebug",
115}
116
117BANCHAN_ENV = {
118    # Use module_arm64 as the canonical banchan target product.
119    "TARGET_PRODUCT": "module_arm64",
120    "TARGET_BUILD_VARIANT": "eng",
121    # just needs to be non-empty, not the specific module for Soong
122    # analysis purposes
123    "TARGET_BUILD_APPS": "all",
124}
125
126_REQUIRED_PROPERTIES = [
127    "Required",
128    "Host_required",
129    "Target_required",
130]
131
132
133def _build_with_soong(target, target_product):
134  env = BANCHAN_ENV if target_product.banchan_mode else LUNCH_ENV
135  if target_product.product:
136    env["TARGET_PRODUCT"] = target_product.product
137
138  subprocess.check_output(
139      [
140          "build/soong/soong_ui.bash",
141          "--make-mode",
142          "--skip-soong-tests",
143          target,
144      ],
145      cwd=SRC_ROOT_DIR,
146      env=env,
147  )
148
149
150def get_properties(json_module):
151  set_properties = {}
152  if "Module" not in json_module:
153    return set_properties
154  if "Android" not in json_module["Module"]:
155    return set_properties
156  if "SetProperties" not in json_module["Module"]["Android"]:
157    return set_properties
158  if json_module["Module"]["Android"]["SetProperties"] is None:
159    return set_properties
160
161  for prop in json_module["Module"]["Android"]["SetProperties"]:
162    if prop["Values"]:
163      value = prop["Values"]
164    else:
165      value = prop["Value"]
166    set_properties[prop["Name"]] = value
167
168  return set_properties
169
170
171def get_property_names(json_module):
172  return get_properties(json_module).keys()
173
174
175def get_queryview_module_info_by_type(types, target_product):
176  """Returns the list of transitive dependencies of input module as built by queryview."""
177  _build_with_soong("queryview", target_product)
178
179  queryview_xml = subprocess.check_output(
180      [
181          "build/bazel/bin/bazel",
182          "query",
183          "--config=ci",
184          "--config=queryview",
185          "--output=xml",
186          # union of queries to get the deps of all Soong modules with the give names
187          " + ".join(
188              f'deps(attr("soong_module_type", "^{t}$", //...))' for t in types
189          ),
190      ],
191      cwd=SRC_ROOT_DIR,
192  )
193  try:
194    return xml.etree.ElementTree.fromstring(queryview_xml)
195  except xml.etree.ElementTree.ParseError as err:
196    sys.exit(f"""Could not parse XML:
197{queryview_xml}
198ParseError: {err}""")
199
200
201def get_queryview_module_info(modules, target_product):
202  """Returns the list of transitive dependencies of input module as built by queryview."""
203  _build_with_soong("queryview", target_product)
204
205  queryview_xml = subprocess.check_output(
206      [
207          "build/bazel/bin/bazel",
208          "query",
209          "--config=ci",
210          "--config=queryview",
211          "--output=xml",
212          # union of queries to get the deps of all Soong modules with the give names
213          " + ".join(
214              f'deps(attr("soong_module_name", "^{m}$", //...))'
215              for m in modules
216          ),
217      ],
218      cwd=SRC_ROOT_DIR,
219  )
220  try:
221    return xml.etree.ElementTree.fromstring(queryview_xml)
222  except xml.etree.ElementTree.ParseError as err:
223    sys.exit(f"""Could not parse XML:
224{queryview_xml}
225ParseError: {err}""")
226
227
228def get_json_module_info(target_product=None):
229  """Returns the list of transitive dependencies of input module as provided by Soong's json module graph."""
230  _build_with_soong("json-module-graph", target_product)
231  try:
232    with open(os.path.join(SRC_ROOT_DIR, "out/soong/module-graph.json")) as f:
233      return json.load(f)
234  except json.JSONDecodeError as err:
235    sys.exit(f"""Could not decode json:
236out/soong/module-graph.json
237JSONDecodeError: {err}""")
238
239
240def ignore_json_module(json_module, ignore_by_name):
241  # windows is not a priority currently
242  if is_windows_variation(json_module):
243    return True
244  if ignore_kind(json_module["Type"]):
245    return True
246  if json_module["Name"] in ignore_by_name:
247    return True
248  # for filegroups with a name the same as the source, we are not migrating the
249  # filegroup and instead just rely on the filename being exported
250  if json_module["Type"] == "filegroup":
251    set_properties = get_properties(json_module)
252    srcs = set_properties.get("Srcs", [])
253    if len(srcs) == 1:
254      return json_module["Name"] in srcs
255  return False
256
257
258def visit_json_module_graph_post_order(
259    module_graph, ignore_by_name, ignore_java_auto_deps, filter_predicate, visit
260):
261  # The set of ignored modules. These modules (and their dependencies) are not shown
262  # in the graph or report.
263  ignored = set()
264
265  # name to all module variants
266  module_graph_map = {}
267  root_module_keys = []
268  name_to_keys = collections.defaultdict(list)
269
270  # Do a single pass to find all top-level modules to be ignored
271  for module in module_graph:
272    name = module["Name"]
273    key = _ModuleKey(name, module["Variations"])
274    if ignore_json_module(module, ignore_by_name):
275      ignored.add(key)
276      continue
277    name_to_keys[name].append(key)
278    module_graph_map[key] = module
279    if filter_predicate(module):
280      root_module_keys.append(key)
281
282  visited = set()
283
284  def json_module_graph_post_traversal(module_key):
285    if module_key in ignored or module_key in visited:
286      return
287    visited.add(module_key)
288
289    deps = set()
290    module = module_graph_map[module_key]
291    created_by = module["CreatedBy"]
292
293    extra_deps = []
294    if created_by:
295      extra_deps.append(created_by)
296
297    set_properties = get_properties(module)
298    for prop in set_properties.keys():
299      for req in _REQUIRED_PROPERTIES:
300        if prop.endswith(req):
301          modules = set_properties.get(prop, [])
302          extra_deps.extend(modules)
303
304    for m in extra_deps:
305      for key in name_to_keys.get(m, []):
306        if key in ignored:
307          continue
308        # treat created by as a dep so it appears as a blocker, otherwise the
309        # module will be disconnected from the traversal graph despite having a
310        # direct relationship to a module and must addressed in the migration
311        deps.add(m)
312        json_module_graph_post_traversal(key)
313
314    # collect all variants and dependencies from those variants
315    # we want to visit all deps before other variants
316    all_variants = {}
317    all_deps = []
318    for k in name_to_keys[module["Name"]]:
319      visited.add(k)
320      m = module_graph_map[k]
321      all_variants[k] = m
322      all_deps.extend(m["Deps"])
323
324    deps_visited = set()
325    for dep in all_deps:
326      dep_name = dep["Name"]
327      dep_key = _ModuleKey(dep_name, dep["Variations"])
328      # only check if we need to ignore or visit each dep once but it might
329      # appear multiple times due to different variants
330      if dep_key in deps_visited:
331        continue
332      deps_visited.add(dep_key)
333
334      if ignore_json_dep(dep, module["Name"], ignored, ignore_java_auto_deps):
335        continue
336
337      deps.add(dep_name)
338      json_module_graph_post_traversal(dep_key)
339
340    for k, m in all_variants.items():
341      visit(m, deps)
342
343  for module_key in root_module_keys:
344    json_module_graph_post_traversal(module_key)
345
346
347QueryviewModule = collections.namedtuple(
348    "QueryviewModule",
349    [
350        "name",
351        "kind",
352        "variant",
353        "dirname",
354        "deps",
355        "srcs",
356    ],
357)
358
359
360def _bazel_target_to_dir(full_target):
361  dirname, _ = full_target.split(":")
362  return dirname[len("//") :]  # discard prefix
363
364
365def _get_queryview_module(name_with_variant, module, kind):
366  name = None
367  variant = ""
368  deps = []
369  srcs = []
370  for attr in module:
371    attr_name = attr.attrib["name"]
372    if attr.tag == "rule-input":
373      deps.append(attr_name)
374    elif attr_name == "soong_module_name":
375      name = attr.attrib["value"]
376    elif attr_name == "soong_module_variant":
377      variant = attr.attrib["value"]
378    elif attr_name == "soong_module_type" and kind == "generic_soong_module":
379      kind = attr.attrib["value"]
380    elif attr_name == "srcs":
381      for item in attr:
382        srcs.append(item.attrib["value"])
383
384  return QueryviewModule(
385      name=name,
386      kind=kind,
387      variant=variant,
388      dirname=_bazel_target_to_dir(name_with_variant),
389      deps=deps,
390      srcs=srcs,
391  )
392
393
394def _ignore_queryview_module(module, ignore_by_name):
395  if module.name in ignore_by_name:
396    return True
397  if ignore_kind(module.kind, queryview=True):
398    return True
399  # special handling for filegroup srcs, if a source has the same name as
400  # the filegroup module, we don't convert it
401  if module.kind == "filegroup" and module.name in module.srcs:
402    return True
403  return module.variant.startswith("windows")
404
405
406def visit_queryview_xml_module_graph_post_order(
407    module_graph, ignored_by_name, filter_predicate, visit
408):
409  # The set of ignored modules. These modules (and their dependencies) are
410  # not shown in the graph or report.
411  ignored = set()
412
413  # queryview embeds variant in long name, keep a map of the name with vaiarnt
414  # to just name
415  name_with_variant_to_name = dict()
416
417  module_graph_map = dict()
418  to_visit = []
419
420  for module in module_graph:
421    ignore = False
422    if module.tag != "rule":
423      continue
424    kind = module.attrib["class"]
425    name_with_variant = module.attrib["name"]
426
427    qv_module = _get_queryview_module(name_with_variant, module, kind)
428
429    if _ignore_queryview_module(qv_module, ignored_by_name):
430      ignored.add(name_with_variant)
431      continue
432
433    if filter_predicate(qv_module):
434      to_visit.append(name_with_variant)
435
436    name_with_variant_to_name.setdefault(name_with_variant, qv_module.name)
437    module_graph_map[name_with_variant] = qv_module
438
439  visited = set()
440
441  def queryview_module_graph_post_traversal(name_with_variant):
442    module = module_graph_map[name_with_variant]
443    if name_with_variant in ignored or name_with_variant in visited:
444      return
445    visited.add(name_with_variant)
446
447    name = name_with_variant_to_name[name_with_variant]
448
449    deps = set()
450    for dep_name_with_variant in module.deps:
451      if dep_name_with_variant in ignored:
452        continue
453      dep_name = name_with_variant_to_name[dep_name_with_variant]
454      if dep_name == "prebuilt_" + name:
455        continue
456      if dep_name_with_variant not in visited:
457        queryview_module_graph_post_traversal(dep_name_with_variant)
458
459      if name != dep_name:
460        deps.add(dep_name)
461
462    visit(module, deps)
463
464  for name_with_variant in to_visit:
465    queryview_module_graph_post_traversal(name_with_variant)
466
467
468def get_bp2build_converted_modules(target_product) -> Dict[str, Set[str]]:
469  """Returns the list of modules that bp2build can currently convert."""
470  _build_with_soong("bp2build", target_product)
471  # Parse the list of converted module names from bp2build
472  with open(
473      os.path.join(
474          SRC_ROOT_DIR,
475          "out/soong/soong_injection/metrics/converted_modules.json",
476      ),
477      "r",
478  ) as f:
479    converted_mods = json.loads(f.read())
480    ret = collections.defaultdict(set)
481    for m in converted_mods:
482      ret[m["name"]].add(m["type"])
483  return ret
484
485
486def get_bp2build_metrics(bp2build_metrics_location):
487  """Returns the bp2build metrics"""
488  bp2build_metrics = Bp2BuildMetrics()
489  with open(
490      os.path.join(bp2build_metrics_location, "bp2build_metrics.pb"), "rb"
491  ) as f:
492    bp2build_metrics.ParseFromString(f.read())
493    f.close()
494  return bp2build_metrics
495
496
497def get_json_module_type_info(module_type, target_product=None):
498  """Returns the combined transitive dependency closures of all modules of module_type."""
499  if target_product is None:
500    target_product = TargetProduct(banchan_mode=False)
501  _build_with_soong("json-module-graph", target_product)
502  # Run query.sh on the module graph for the top level module type
503  result = subprocess.check_output(
504      [
505          "build/bazel/json_module_graph/query.sh",
506          "fullTransitiveModuleTypeDeps",
507          "out/soong/module-graph.json",
508          module_type,
509      ],
510      cwd=SRC_ROOT_DIR,
511  )
512  return json.loads(result)
513
514
515def is_windows_variation(module):
516  """Returns True if input module's variant is Windows.
517
518  Args:
519    module: an entry parsed from Soong's json-module-graph
520  """
521  dep_variations = module.get("Variations")
522  dep_variation_os = ""
523  if dep_variations != None:
524    for v in dep_variations:
525      if v["Mutator"] == "os":
526        dep_variation_os = v["Variation"]
527  return dep_variation_os == "windows"
528
529
530def ignore_kind(kind, queryview=False):
531  if queryview and kind in _QUERYVIEW_IGNORE_KINDS:
532    return True
533  return kind in IGNORED_KINDS or "defaults" in kind
534
535
536def is_prebuilt_to_source_dep(dep):
537  # Soong always adds a dependency from a source module to its corresponding
538  # prebuilt module, if it exists.
539  # https://cs.android.com/android/platform/superproject/+/master:build/soong/android/prebuilt.go;l=395-396;drc=5d6fa4d8571d01a6e5a63a8b7aa15e61f45737a9
540  # This makes it appear that the prebuilt is a transitive dependency regardless
541  # of whether it is actually necessary. Skip these to keep the graph to modules
542  # used to build.
543  return dep["Tag"] == "android.prebuiltDependencyTag {BaseDependencyTag:{}}"
544
545
546def _is_toolchain_dep(dep):
547  return dep["Tag"] in _TOOLCHAIN_DEP_TYPES
548
549
550def _is_java_auto_dep(dep):
551  # Soong adds a number of dependencies automatically for Java deps, making it
552  # difficult to understand the actual dependencies, remove the
553  # non-user-specified deps
554  tag = dep["Tag"]
555  if not tag:
556    return False
557
558  if tag.startswith("java.dependencyTag") and (
559      "name:system modules" in tag or "name:bootclasspath" in tag
560  ):
561    name = dep["Name"]
562    # only remove automatically added bootclasspath/system modules
563    return (
564        name
565        in frozenset([
566            "core-lambda-stubs",
567            "core-module-lib-stubs-system-modules",
568            "core-public-stubs-system-modules",
569            "core-system-server-stubs-system-modules",
570            "core-system-stubs-system-modules",
571            "core-test-stubs-system-modules",
572            "core.current.stubs",
573            "legacy-core-platform-api-stubs-system-modules",
574            "legacy.core.platform.api.stubs",
575            "stable-core-platform-api-stubs-system-modules",
576            "stable.core.platform.api.stubs",
577        ])
578        or (name.startswith("android_") and name.endswith("_stubs_current"))
579        or (name.startswith("sdk_") and name.endswith("_system_modules"))
580    )
581  return (
582      (
583          tag.startswith("java.dependencyTag")
584          and (
585              "name:proguard-raise" in tag
586              or "name:framework-res" in tag
587              or "name:sdklib" in tag
588              or "name:java9lib" in tag
589          )
590          or (
591              tag.startswith("java.usesLibraryDependencyTag")
592              or tag.startswith("java.hiddenAPIStubsDependencyTag")
593          )
594      )
595      or (
596          tag.startswith("android.sdkMemberDependencyTag")
597          or tag.startswith("java.scopeDependencyTag")
598      )
599      or tag.startswith("dexpreopt.dex2oatDependencyTag")
600  )
601
602
603def ignore_json_dep(dep, module_name, ignored_keys, ignore_java_auto_deps):
604  """Whether to ignore a json dependency based on heuristics.
605
606  Args:
607    dep: dependency struct from an entry in Soogn's json-module-graph
608    module_name: name of the module this is a dependency of
609    ignored_names: a set of _ModuleKey to ignore
610  """
611  if is_prebuilt_to_source_dep(dep):
612    return True
613  if _is_toolchain_dep(dep):
614    return True
615  elif dep["Name"] == "py3-stdlib":
616    return True
617  if ignore_java_auto_deps and _is_java_auto_dep(dep):
618    return True
619  name = dep["Name"]
620  return (
621      _ModuleKey(name, dep["Variations"]) in ignored_keys or name == module_name
622  )
623