xref: /aosp_15_r20/tools/asuite/atest/bazel_mode.py (revision c2e18aaa1096c836b086f94603d04f4eb9cf37f5)
1# Copyright 2021, The Android Open Source Project
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#     http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Implementation of Atest's Bazel mode.
16
17Bazel mode runs tests using Bazel by generating a synthetic workspace that
18contains test targets. Using Bazel allows Atest to leverage features such as
19sandboxing, caching, and remote execution.
20"""
21# pylint: disable=missing-function-docstring
22# pylint: disable=missing-class-docstring
23# pylint: disable=too-many-lines
24
25from __future__ import annotations
26
27from abc import ABC, abstractmethod
28import argparse
29import atexit
30from collections import OrderedDict, defaultdict, deque
31from collections.abc import Iterable
32import contextlib
33import dataclasses
34import enum
35import functools
36import importlib.resources
37import logging
38import os
39import pathlib
40import re
41import shlex
42import shutil
43import subprocess
44import tempfile
45import time
46from types import MappingProxyType
47from typing import Any, Callable, Dict, IO, List, Set, Tuple
48import warnings
49from xml.etree import ElementTree as ET
50
51from atest import atest_configs
52from atest import atest_utils
53from atest import constants
54from atest import module_info
55from atest.atest_enum import DetectType, ExitCode
56from atest.metrics import metrics
57from atest.proto import file_md5_pb2
58from atest.test_finders import test_finder_base
59from atest.test_finders import test_info
60from atest.test_runners import atest_tf_test_runner as tfr
61from atest.test_runners import test_runner_base as trb
62from google.protobuf.message import DecodeError
63
64
65JDK_PACKAGE_NAME = 'prebuilts/robolectric_jdk'
66JDK_NAME = 'jdk'
67ROBOLECTRIC_CONFIG = 'build/make/core/robolectric_test_config_template.xml'
68
69BAZEL_TEST_LOGS_DIR_NAME = 'bazel-testlogs'
70TEST_OUTPUT_DIR_NAME = 'test.outputs'
71TEST_OUTPUT_ZIP_NAME = 'outputs.zip'
72
73_BAZEL_WORKSPACE_DIR = 'atest_bazel_workspace'
74_SUPPORTED_BAZEL_ARGS = MappingProxyType({
75    # https://docs.bazel.build/versions/main/command-line-reference.html#flag--runs_per_test
76    constants.ITERATIONS: lambda arg_value: [
77        f'--runs_per_test={str(arg_value)}'
78    ],
79    # https://docs.bazel.build/versions/main/command-line-reference.html#flag--flaky_test_attempts
80    constants.RETRY_ANY_FAILURE: lambda arg_value: [
81        f'--flaky_test_attempts={str(arg_value)}'
82    ],
83    # https://docs.bazel.build/versions/main/command-line-reference.html#flag--test_output
84    constants.VERBOSE: (
85        lambda arg_value: ['--test_output=all'] if arg_value else []
86    ),
87    constants.BAZEL_ARG: lambda arg_value: [
88        item for sublist in arg_value for item in sublist
89    ],
90})
91
92# Maps Bazel configuration names to Soong variant names.
93_CONFIG_TO_VARIANT = {
94    'host': 'host',
95    'device': 'target',
96}
97
98
99class AbortRunException(Exception):
100  pass
101
102
103@enum.unique
104class Features(enum.Enum):
105  NULL_FEATURE = ('--null-feature', 'Enables a no-action feature.', True)
106  EXPERIMENTAL_DEVICE_DRIVEN_TEST = (
107      '--experimental-device-driven-test',
108      'Enables running device-driven tests in Bazel mode.',
109      True,
110  )
111  EXPERIMENTAL_REMOTE_AVD = (
112      '--experimental-remote-avd',
113      'Enables running device-driven tests in remote AVD.',
114      False,
115  )
116  EXPERIMENTAL_BES_PUBLISH = (
117      '--experimental-bes-publish',
118      'Upload test results via BES in Bazel mode.',
119      False,
120  )
121  EXPERIMENTAL_JAVA_RUNTIME_DEPENDENCIES = (
122      '--experimental-java-runtime-dependencies',
123      (
124          'Mirrors Soong Java `libs` and `static_libs` as Bazel target '
125          'dependencies in the generated workspace. Tradefed test rules use '
126          'these dependencies to set up the execution environment and ensure '
127          'that all transitive runtime dependencies are present.'
128      ),
129      True,
130  )
131  EXPERIMENTAL_REMOTE = (
132      '--experimental-remote',
133      'Use Bazel remote execution and caching where supported.',
134      False,
135  )
136  EXPERIMENTAL_HOST_DRIVEN_TEST = (
137      '--experimental-host-driven-test',
138      'Enables running host-driven device tests in Bazel mode.',
139      True,
140  )
141  EXPERIMENTAL_ROBOLECTRIC_TEST = (
142      '--experimental-robolectric-test',
143      'Enables running Robolectric tests in Bazel mode.',
144      True,
145  )
146  NO_BAZEL_DETAILED_SUMMARY = (
147      '--no-bazel-detailed-summary',
148      'Disables printing detailed summary of Bazel test results.',
149      False,
150  )
151
152  def __init__(self, arg_flag, description, affects_workspace):
153    self._arg_flag = arg_flag
154    self._description = description
155    self.affects_workspace = affects_workspace
156
157  @property
158  def arg_flag(self):
159    return self._arg_flag
160
161  @property
162  def description(self):
163    return self._description
164
165
166def add_parser_arguments(parser: argparse.ArgumentParser, dest: str):
167  for _, member in Features.__members__.items():
168    parser.add_argument(
169        member.arg_flag,
170        action='append_const',
171        const=member,
172        dest=dest,
173        help=member.description,
174    )
175
176
177def get_bazel_workspace_dir() -> pathlib.Path:
178  return atest_utils.get_build_out_dir(_BAZEL_WORKSPACE_DIR)
179
180
181def generate_bazel_workspace(
182    mod_info: module_info.ModuleInfo, enabled_features: Set[Features] = None
183):
184  """Generate or update the Bazel workspace used for running tests."""
185
186  start = time.time()
187  src_root_path = pathlib.Path(os.environ.get(constants.ANDROID_BUILD_TOP))
188  workspace_path = get_bazel_workspace_dir()
189  resource_manager = ResourceManager(
190      src_root_path=src_root_path,
191      resource_root_path=_get_resource_root(),
192      product_out_path=pathlib.Path(
193          os.environ.get(constants.ANDROID_PRODUCT_OUT)
194      ),
195      md5_checksum_file_path=workspace_path.joinpath('workspace_md5_checksum'),
196  )
197  jdk_path = _read_robolectric_jdk_path(
198      resource_manager.get_src_file_path(ROBOLECTRIC_CONFIG, True)
199  )
200
201  workspace_generator = WorkspaceGenerator(
202      resource_manager=resource_manager,
203      workspace_out_path=workspace_path,
204      host_out_path=pathlib.Path(os.environ.get(constants.ANDROID_HOST_OUT)),
205      build_out_dir=atest_utils.get_build_out_dir(),
206      mod_info=mod_info,
207      jdk_path=jdk_path,
208      enabled_features=enabled_features,
209  )
210  workspace_generator.generate()
211
212  metrics.LocalDetectEvent(
213      detect_type=DetectType.BAZEL_WORKSPACE_GENERATE_TIME,
214      result=int(time.time() - start),
215  )
216
217
218def get_default_build_metadata():
219  return BuildMetadata(
220      atest_utils.get_manifest_branch(), atest_utils.get_build_target()
221  )
222
223
224class ResourceManager:
225  """Class for managing files required to generate a Bazel Workspace."""
226
227  def __init__(
228      self,
229      src_root_path: pathlib.Path,
230      resource_root_path: pathlib.Path,
231      product_out_path: pathlib.Path,
232      md5_checksum_file_path: pathlib.Path,
233  ):
234    self._root_type_to_path = {
235        file_md5_pb2.RootType.SRC_ROOT: src_root_path,
236        file_md5_pb2.RootType.RESOURCE_ROOT: resource_root_path,
237        file_md5_pb2.RootType.ABS_PATH: pathlib.Path(),
238        file_md5_pb2.RootType.PRODUCT_OUT: product_out_path,
239    }
240    self._md5_checksum_file = md5_checksum_file_path
241    self._file_checksum_list = file_md5_pb2.FileChecksumList()
242
243  def get_src_file_path(
244      self, rel_path: pathlib.Path = None, affects_workspace: bool = False
245  ) -> pathlib.Path:
246    """Get the abs file path from the relative path of source_root.
247
248    Args:
249        rel_path: A relative path of the source_root.
250        affects_workspace: A boolean of whether the file affects the workspace.
251
252    Returns:
253        A abs path of the file.
254    """
255    return self._get_file_path(
256        file_md5_pb2.RootType.SRC_ROOT, rel_path, affects_workspace
257    )
258
259  def get_resource_file_path(
260      self,
261      rel_path: pathlib.Path = None,
262      affects_workspace: bool = False,
263  ) -> pathlib.Path:
264    """Get the abs file path from the relative path of resource_root.
265
266    Args:
267        rel_path: A relative path of the resource_root.
268        affects_workspace: A boolean of whether the file affects the workspace.
269
270    Returns:
271        A abs path of the file.
272    """
273    return self._get_file_path(
274        file_md5_pb2.RootType.RESOURCE_ROOT, rel_path, affects_workspace
275    )
276
277  def get_product_out_file_path(
278      self, rel_path: pathlib.Path = None, affects_workspace: bool = False
279  ) -> pathlib.Path:
280    """Get the abs file path from the relative path of product out.
281
282    Args:
283        rel_path: A relative path to the product out.
284        affects_workspace: A boolean of whether the file affects the workspace.
285
286    Returns:
287        An abs path of the file.
288    """
289    return self._get_file_path(
290        file_md5_pb2.RootType.PRODUCT_OUT, rel_path, affects_workspace
291    )
292
293  def _get_file_path(
294      self,
295      root_type: file_md5_pb2.RootType,
296      rel_path: pathlib.Path,
297      affects_workspace: bool = True,
298  ) -> pathlib.Path:
299    abs_path = self._root_type_to_path[root_type].joinpath(
300        rel_path or pathlib.Path()
301    )
302
303    if not affects_workspace:
304      return abs_path
305
306    if abs_path.is_dir():
307      for file in abs_path.glob('**/*'):
308        self._register_file(root_type, file)
309    else:
310      self._register_file(root_type, abs_path)
311    return abs_path
312
313  def _register_file(
314      self, root_type: file_md5_pb2.RootType, abs_path: pathlib.Path
315  ):
316    if not abs_path.is_file():
317      logging.debug(' ignore %s: not a file.', abs_path)
318      return
319
320    rel_path = abs_path
321    if abs_path.is_relative_to(self._root_type_to_path[root_type]):
322      rel_path = abs_path.relative_to(self._root_type_to_path[root_type])
323
324    self._file_checksum_list.file_checksums.append(
325        file_md5_pb2.FileChecksum(
326            root_type=root_type,
327            rel_path=str(rel_path),
328            md5sum=atest_utils.md5sum(abs_path),
329        )
330    )
331
332  def register_file_with_abs_path(self, abs_path: pathlib.Path):
333    """Register a file which affects the workspace.
334
335    Args:
336        abs_path: A abs path of the file.
337    """
338    self._register_file(file_md5_pb2.RootType.ABS_PATH, abs_path)
339
340  def save_affects_files_md5(self):
341    with open(self._md5_checksum_file, 'wb') as f:
342      f.write(self._file_checksum_list.SerializeToString())
343
344  def check_affects_files_md5(self):
345    """Check all affect files are consistent with the actual MD5."""
346    if not self._md5_checksum_file.is_file():
347      return False
348
349    with open(self._md5_checksum_file, 'rb') as f:
350      file_md5_list = file_md5_pb2.FileChecksumList()
351
352      try:
353        file_md5_list.ParseFromString(f.read())
354      except DecodeError:
355        atest_utils.print_and_log_warning(
356            'Failed to parse the workspace md5 checksum file.'
357        )
358        return False
359
360      for file_md5 in file_md5_list.file_checksums:
361        abs_path = pathlib.Path(
362            self._root_type_to_path[file_md5.root_type]
363        ).joinpath(file_md5.rel_path)
364        if not abs_path.is_file():
365          return False
366        if atest_utils.md5sum(abs_path) != file_md5.md5sum:
367          return False
368      return True
369
370
371class WorkspaceGenerator:
372  """Class for generating a Bazel workspace."""
373
374  # pylint: disable=too-many-arguments
375  def __init__(
376      self,
377      resource_manager: ResourceManager,
378      workspace_out_path: pathlib.Path,
379      host_out_path: pathlib.Path,
380      build_out_dir: pathlib.Path,
381      mod_info: module_info.ModuleInfo,
382      jdk_path: pathlib.Path = None,
383      enabled_features: Set[Features] = None,
384  ):
385    """Initializes the generator.
386
387    Args:
388        workspace_out_path: Path where the workspace will be output.
389        host_out_path: Path of the ANDROID_HOST_OUT.
390        build_out_dir: Path of OUT_DIR
391        mod_info: ModuleInfo object.
392        enabled_features: Set of enabled features.
393    """
394    if (
395        enabled_features
396        and Features.EXPERIMENTAL_REMOTE_AVD in enabled_features
397        and Features.EXPERIMENTAL_DEVICE_DRIVEN_TEST not in enabled_features
398    ):
399      raise ValueError(
400          'Cannot run remote device test because '
401          '"--experimental-device-driven-test" flag is'
402          ' not set.'
403      )
404    self.enabled_features = enabled_features or set()
405    self.resource_manager = resource_manager
406    self.workspace_out_path = workspace_out_path
407    self.host_out_path = host_out_path
408    self.build_out_dir = build_out_dir
409    self.mod_info = mod_info
410    self.path_to_package = {}
411    self.jdk_path = jdk_path
412
413  def generate(self):
414    """Generate a Bazel workspace.
415
416    If the workspace md5 checksum file doesn't exist or is stale, a new
417    workspace will be generated. Otherwise, the existing workspace will be
418    reused.
419    """
420    start = time.time()
421    enabled_features_file = self.workspace_out_path.joinpath(
422        'atest_bazel_mode_enabled_features'
423    )
424    enabled_features_file_contents = '\n'.join(
425        sorted(f.name for f in self.enabled_features if f.affects_workspace)
426    )
427
428    if self.workspace_out_path.exists():
429      # Update the file with the set of the currently enabled features to
430      # make sure that changes are detected in the workspace checksum.
431      enabled_features_file.write_text(enabled_features_file_contents)
432      if self.resource_manager.check_affects_files_md5():
433        return
434
435      # We raise an exception if rmtree fails to avoid leaving stale
436      # files in the workspace that could interfere with execution.
437      shutil.rmtree(self.workspace_out_path)
438
439    atest_utils.colorful_print('Generating Bazel workspace.\n', constants.RED)
440
441    self._add_test_module_targets()
442
443    self.workspace_out_path.mkdir(parents=True)
444    self._generate_artifacts()
445
446    # Note that we write the set of enabled features despite having written
447    # it above since the workspace no longer exists at this point.
448    enabled_features_file.write_text(enabled_features_file_contents)
449
450    self.resource_manager.get_product_out_file_path(
451        self.mod_info.mod_info_file_path.relative_to(
452            self.resource_manager.get_product_out_file_path()
453        ),
454        True,
455    )
456    self.resource_manager.register_file_with_abs_path(enabled_features_file)
457    self.resource_manager.save_affects_files_md5()
458    metrics.LocalDetectEvent(
459        detect_type=DetectType.FULL_GENERATE_BAZEL_WORKSPACE_TIME,
460        result=int(time.time() - start),
461    )
462
463  def _add_test_module_targets(self):
464    seen = set()
465
466    for name, info in self.mod_info.name_to_module_info.items():
467      # Ignore modules that have a 'host_cross_' prefix since they are
468      # duplicates of existing modules. For example,
469      # 'host_cross_aapt2_tests' is a duplicate of 'aapt2_tests'. We also
470      # ignore modules with a '_32' suffix since these also are redundant
471      # given that modules have both 32 and 64-bit variants built by
472      # default. See b/77288544#comment6 and b/23566667 for more context.
473      if name.endswith('_32') or name.startswith('host_cross_'):
474        continue
475
476      if (
477          Features.EXPERIMENTAL_DEVICE_DRIVEN_TEST in self.enabled_features
478          and self.mod_info.is_device_driven_test(info)
479      ):
480        self._resolve_dependencies(
481            self._add_device_test_target(info, False), seen
482        )
483
484      if self.mod_info.is_host_unit_test(info):
485        self._resolve_dependencies(self._add_deviceless_test_target(info), seen)
486      elif (
487          Features.EXPERIMENTAL_ROBOLECTRIC_TEST in self.enabled_features
488          and self.mod_info.is_modern_robolectric_test(info)
489      ):
490        self._resolve_dependencies(
491            self._add_tradefed_robolectric_test_target(info), seen
492        )
493      elif (
494          Features.EXPERIMENTAL_HOST_DRIVEN_TEST in self.enabled_features
495          and self.mod_info.is_host_driven_test(info)
496      ):
497        self._resolve_dependencies(
498            self._add_device_test_target(info, True), seen
499        )
500
501  def _resolve_dependencies(self, top_level_target: Target, seen: Set[Target]):
502
503    stack = [deque([top_level_target])]
504
505    while stack:
506      top = stack[-1]
507
508      if not top:
509        stack.pop()
510        continue
511
512      target = top.popleft()
513
514      # Note that we're relying on Python's default identity-based hash
515      # and equality methods. This is fine since we actually DO want
516      # reference-equality semantics for Target objects in this context.
517      if target in seen:
518        continue
519
520      seen.add(target)
521
522      next_top = deque()
523
524      for ref in target.dependencies():
525        info = ref.info or self._get_module_info(ref.name)
526        ref.set(self._add_prebuilt_target(info))
527        next_top.append(ref.target())
528
529      stack.append(next_top)
530
531  def _add_device_test_target(
532      self, info: Dict[str, Any], is_host_driven: bool
533  ) -> Target:
534    package_name = self._get_module_path(info)
535    name_suffix = 'host' if is_host_driven else 'device'
536    name = f'{info[constants.MODULE_INFO_ID]}_{name_suffix}'
537
538    def create():
539      return TestTarget.create_device_test_target(
540          name,
541          package_name,
542          info,
543          is_host_driven,
544      )
545
546    return self._add_target(package_name, name, create)
547
548  def _add_deviceless_test_target(self, info: Dict[str, Any]) -> Target:
549    package_name = self._get_module_path(info)
550    name = f'{info[constants.MODULE_INFO_ID]}_host'
551
552    def create():
553      return TestTarget.create_deviceless_test_target(
554          name,
555          package_name,
556          info,
557      )
558
559    return self._add_target(package_name, name, create)
560
561  def _add_tradefed_robolectric_test_target(
562      self, info: Dict[str, Any]
563  ) -> Target:
564    package_name = self._get_module_path(info)
565    name = f'{info[constants.MODULE_INFO_ID]}_host'
566
567    return self._add_target(
568        package_name,
569        name,
570        lambda: TestTarget.create_tradefed_robolectric_test_target(
571            name, package_name, info, f'//{JDK_PACKAGE_NAME}:{JDK_NAME}'
572        ),
573    )
574
575  def _add_prebuilt_target(self, info: Dict[str, Any]) -> Target:
576    package_name = self._get_module_path(info)
577    name = info[constants.MODULE_INFO_ID]
578
579    def create():
580      return SoongPrebuiltTarget.create(
581          self,
582          info,
583          package_name,
584      )
585
586    return self._add_target(package_name, name, create)
587
588  def _add_target(
589      self, package_path: str, target_name: str, create_fn: Callable
590  ) -> Target:
591
592    package = self.path_to_package.get(package_path)
593
594    if not package:
595      package = Package(package_path)
596      self.path_to_package[package_path] = package
597
598    target = package.get_target(target_name)
599
600    if target:
601      return target
602
603    target = create_fn()
604    package.add_target(target)
605
606    return target
607
608  def _get_module_info(self, module_name: str) -> Dict[str, Any]:
609    info = self.mod_info.get_module_info(module_name)
610
611    if not info:
612      raise LookupError(
613          f'Could not find module `{module_name}` in module_info file'
614      )
615
616    return info
617
618  def _get_module_path(self, info: Dict[str, Any]) -> str:
619    mod_path = info.get(constants.MODULE_PATH)
620
621    if len(mod_path) < 1:
622      module_name = info['module_name']
623      raise ValueError(f'Module `{module_name}` does not have any path')
624
625    if len(mod_path) > 1:
626      module_name = info['module_name']
627      # We usually have a single path but there are a few exceptions for
628      # modules like libLLVM_android and libclang_android.
629      # TODO(yangbill): Raise an exception for multiple paths once
630      # b/233581382 is resolved.
631      warnings.formatwarning = lambda msg, *args, **kwargs: f'{msg}\n'
632      warnings.warn(
633          f'Module `{module_name}` has more than one path: `{mod_path}`'
634      )
635
636    return mod_path[0]
637
638  def _generate_artifacts(self):
639    """Generate workspace files on disk."""
640
641    self._create_base_files()
642
643    self._add_workspace_resource(src='rules', dst='bazel/rules')
644    self._add_workspace_resource(src='configs', dst='bazel/configs')
645
646    if Features.EXPERIMENTAL_DEVICE_DRIVEN_TEST in self.enabled_features:
647      self._add_workspace_resource(src='device_def', dst='device_def')
648
649    self._add_bazel_bootstrap_files()
650
651    # Symlink to package with toolchain definitions.
652    self._symlink(src='prebuilts/build-tools', target='prebuilts/build-tools')
653
654    device_infra_path = 'vendor/google/tools/atest/device_infra'
655    if self.resource_manager.get_src_file_path(device_infra_path).exists():
656      self._symlink(src=device_infra_path, target=device_infra_path)
657
658    self._link_required_src_file_path('external/bazelbuild-rules_python')
659    self._link_required_src_file_path('external/bazelbuild-rules_java')
660
661    self._create_constants_file()
662
663    self._generate_robolectric_resources()
664
665    for package in self.path_to_package.values():
666      package.generate(self.workspace_out_path)
667
668  def _generate_robolectric_resources(self):
669    if not self.jdk_path:
670      return
671
672    self._generate_jdk_resources()
673    self._generate_android_all_resources()
674
675  def _generate_jdk_resources(self):
676    # TODO(b/265596946): Create the JDK toolchain instead of using
677    # a filegroup.
678    return self._add_target(
679        JDK_PACKAGE_NAME,
680        JDK_NAME,
681        lambda: FilegroupTarget(
682            JDK_PACKAGE_NAME,
683            JDK_NAME,
684            self.resource_manager.get_src_file_path(self.jdk_path),
685        ),
686    )
687
688  def _generate_android_all_resources(self):
689    package_name = 'android-all'
690    name = 'android-all'
691
692    return self._add_target(
693        package_name,
694        name,
695        lambda: FilegroupTarget(
696            package_name, name, self.host_out_path.joinpath(f'testcases/{name}')
697        ),
698    )
699
700  def _symlink(self, *, src, target):
701    """Create a symbolic link in workspace pointing to source file/dir.
702
703    Args:
704        src: A string of a relative path to root of Android source tree. This is
705          the source file/dir path for which the symbolic link will be created.
706        target: A string of a relative path to workspace root. This is the
707          target file/dir path where the symbolic link will be created.
708    """
709    symlink = self.workspace_out_path.joinpath(target)
710    symlink.parent.mkdir(parents=True, exist_ok=True)
711    symlink.symlink_to(self.resource_manager.get_src_file_path(src))
712
713  def _create_base_files(self):
714    self._add_workspace_resource(src='WORKSPACE', dst='WORKSPACE')
715    self._add_workspace_resource(src='bazelrc', dst='.bazelrc')
716
717    self.workspace_out_path.joinpath('BUILD.bazel').touch()
718
719  def _add_bazel_bootstrap_files(self):
720    self._add_workspace_resource(src='bazel.sh', dst='bazel.sh')
721    # Restore permissions as execute permissions are not preserved by soong
722    # packaging.
723    os.chmod(self.workspace_out_path.joinpath('bazel.sh'), 0o755)
724    self._symlink(
725        src='prebuilts/jdk/jdk21/BUILD.bazel',
726        target='prebuilts/jdk/jdk21/BUILD.bazel',
727    )
728    self._symlink(
729        src='prebuilts/jdk/jdk21/linux-x86',
730        target='prebuilts/jdk/jdk21/linux-x86',
731    )
732    self._symlink(
733        src='prebuilts/bazel/linux-x86_64/bazel',
734        target='prebuilts/bazel/linux-x86_64/bazel',
735    )
736
737  def _add_workspace_resource(self, src, dst):
738    """Add resource to the given destination in workspace.
739
740    Args:
741        src: A string of a relative path to root of Bazel artifacts. This is the
742          source file/dir path that will be added to workspace.
743        dst: A string of a relative path to workspace root. This is the
744          destination file/dir path where the artifacts will be added.
745    """
746    src = self.resource_manager.get_resource_file_path(src, True)
747    dst = self.workspace_out_path.joinpath(dst)
748    dst.parent.mkdir(parents=True, exist_ok=True)
749
750    if src.is_file():
751      shutil.copy(src, dst)
752    else:
753      shutil.copytree(src, dst, ignore=shutil.ignore_patterns('__init__.py'))
754
755  def _create_constants_file(self):
756    def variable_name(target_name):
757      return re.sub(r'[.-]', '_', target_name) + '_label'
758
759    targets = []
760    seen = set()
761
762    for module_name in TestTarget.DEVICELESS_TEST_PREREQUISITES.union(
763        TestTarget.DEVICE_TEST_PREREQUISITES
764    ):
765      info = self.mod_info.get_module_info(module_name)
766      target = self._add_prebuilt_target(info)
767      self._resolve_dependencies(target, seen)
768      targets.append(target)
769
770    with self.workspace_out_path.joinpath('constants.bzl').open('w') as f:
771      writer = IndentWriter(f)
772      for target in targets:
773        writer.write_line(
774            '%s = "%s"'
775            % (variable_name(target.name()), target.qualified_name())
776        )
777
778  def _link_required_src_file_path(self, path):
779    if not self.resource_manager.get_src_file_path(path).exists():
780      raise RuntimeError(f'Path `{path}` does not exist in source tree.')
781
782    self._symlink(src=path, target=path)
783
784
785@functools.cache
786def _get_resource_root() -> pathlib.Path:
787  tmp_resource_dir = pathlib.Path(tempfile.mkdtemp())
788  atexit.register(lambda: shutil.rmtree(tmp_resource_dir))
789
790  def _extract_resources(
791      resource_path: pathlib.Path,
792      dst: pathlib.Path,
793      ignore_file_names: list[str] = None,
794  ):
795    resource = importlib.resources.files(resource_path.as_posix())
796    dst.mkdir(parents=True, exist_ok=True)
797    for child in resource.iterdir():
798      if child.is_file():
799        if child.name in ignore_file_names:
800          continue
801        with importlib.resources.as_file(child) as child_file:
802          shutil.copy(child_file, dst.joinpath(child.name))
803      elif child.is_dir():
804        _extract_resources(
805            resource_path.joinpath(child.name),
806            dst.joinpath(child.name),
807            ignore_file_names,
808        )
809      else:
810        atest_utils.print_and_log_warning(
811            'Ignoring unknown resource: %s', child
812        )
813
814  try:
815    _extract_resources(
816        pathlib.Path('atest/bazel/resources'),
817        tmp_resource_dir,
818        ignore_file_names=['__init__.py'],
819    )
820  except ModuleNotFoundError as e:
821    logging.debug(
822        'Bazel resource not found from package path, possible due to running'
823        ' atest from source. Returning resource source path instead: %s',
824        e,
825    )
826    return pathlib.Path(os.path.dirname(__file__)).joinpath('bazel/resources')
827
828  return tmp_resource_dir
829
830
831class Package:
832  """Class for generating an entire Package on disk."""
833
834  def __init__(self, path: str):
835    self.path = path
836    self.imports = defaultdict(set)
837    self.name_to_target = OrderedDict()
838
839  def add_target(self, target):
840    target_name = target.name()
841
842    if target_name in self.name_to_target:
843      raise ValueError(
844          f'Cannot add target `{target_name}` which already'
845          f' exists in package `{self.path}`'
846      )
847
848    self.name_to_target[target_name] = target
849
850    for i in target.required_imports():
851      self.imports[i.bzl_package].add(i.symbol)
852
853  def generate(self, workspace_out_path: pathlib.Path):
854    package_dir = workspace_out_path.joinpath(self.path)
855    package_dir.mkdir(parents=True, exist_ok=True)
856
857    self._create_filesystem_layout(package_dir)
858    self._write_build_file(package_dir)
859
860  def _create_filesystem_layout(self, package_dir: pathlib.Path):
861    for target in self.name_to_target.values():
862      target.create_filesystem_layout(package_dir)
863
864  def _write_build_file(self, package_dir: pathlib.Path):
865    with package_dir.joinpath('BUILD.bazel').open('w') as f:
866      f.write('package(default_visibility = ["//visibility:public"])\n')
867      f.write('\n')
868
869      for bzl_package, symbols in sorted(self.imports.items()):
870        symbols_text = ', '.join('"%s"' % s for s in sorted(symbols))
871        f.write(f'load("{bzl_package}", {symbols_text})\n')
872
873      for target in self.name_to_target.values():
874        f.write('\n')
875        target.write_to_build_file(f)
876
877  def get_target(self, target_name: str) -> Target:
878    return self.name_to_target.get(target_name, None)
879
880
881@dataclasses.dataclass(frozen=True)
882class Import:
883  bzl_package: str
884  symbol: str
885
886
887@dataclasses.dataclass(frozen=True)
888class Config:
889  name: str
890  out_path: pathlib.Path
891
892
893class ModuleRef:
894
895  @staticmethod
896  def for_info(info) -> ModuleRef:
897    return ModuleRef(info=info)
898
899  @staticmethod
900  def for_name(name) -> ModuleRef:
901    return ModuleRef(name=name)
902
903  def __init__(self, info=None, name=None):
904    self.info = info
905    self.name = name
906    self._target = None
907
908  def target(self) -> Target:
909    if not self._target:
910      target_name = self.info[constants.MODULE_INFO_ID]
911      raise ValueError(f'Target not set for ref `{target_name}`')
912
913    return self._target
914
915  def set(self, target):
916    self._target = target
917
918
919class Target(ABC):
920  """Abstract class for a Bazel target."""
921
922  @abstractmethod
923  def name(self) -> str:
924    pass
925
926  def package_name(self) -> str:
927    pass
928
929  def qualified_name(self) -> str:
930    return f'//{self.package_name()}:{self.name()}'
931
932  def required_imports(self) -> Set[Import]:
933    return set()
934
935  def supported_configs(self) -> Set[Config]:
936    return set()
937
938  def dependencies(self) -> List[ModuleRef]:
939    return []
940
941  def write_to_build_file(self, f: IO):
942    pass
943
944  def create_filesystem_layout(self, package_dir: pathlib.Path):
945    pass
946
947
948class FilegroupTarget(Target):
949
950  def __init__(
951      self, package_name: str, target_name: str, srcs_root: pathlib.Path
952  ):
953    self._package_name = package_name
954    self._target_name = target_name
955    self._srcs_root = srcs_root
956
957  def name(self) -> str:
958    return self._target_name
959
960  def package_name(self) -> str:
961    return self._package_name
962
963  def write_to_build_file(self, f: IO):
964    writer = IndentWriter(f)
965    build_file_writer = BuildFileWriter(writer)
966
967    writer.write_line('filegroup(')
968
969    with writer.indent():
970      build_file_writer.write_string_attribute('name', self._target_name)
971      build_file_writer.write_glob_attribute(
972          'srcs', [f'{self._target_name}_files/**']
973      )
974
975    writer.write_line(')')
976
977  def create_filesystem_layout(self, package_dir: pathlib.Path):
978    symlink = package_dir.joinpath(f'{self._target_name}_files')
979    symlink.symlink_to(self._srcs_root)
980
981
982class TestTarget(Target):
983  """Class for generating a test target."""
984
985  DEVICELESS_TEST_PREREQUISITES = frozenset({
986      'adb',
987      'atest-tradefed',
988      'atest_script_help.sh',
989      'atest_tradefed.sh',
990      'tradefed',
991      'tradefed-test-framework',
992      'bazel-result-reporter',
993  })
994
995  DEVICE_TEST_PREREQUISITES = frozenset(
996      DEVICELESS_TEST_PREREQUISITES.union(
997          frozenset({
998              'aapt',
999              'aapt2',
1000              'compatibility-tradefed',
1001              'vts-core-tradefed-harness',
1002          })
1003      )
1004  )
1005
1006  @staticmethod
1007  def create_deviceless_test_target(
1008      name: str, package_name: str, info: Dict[str, Any]
1009  ):
1010    return TestTarget(
1011        package_name,
1012        'tradefed_deviceless_test',
1013        {
1014            'name': name,
1015            'test': ModuleRef.for_info(info),
1016            'module_name': info['module_name'],
1017            'tags': info.get(constants.MODULE_TEST_OPTIONS_TAGS, []),
1018        },
1019        TestTarget.DEVICELESS_TEST_PREREQUISITES,
1020    )
1021
1022  @staticmethod
1023  def create_device_test_target(
1024      name: str, package_name: str, info: Dict[str, Any], is_host_driven: bool
1025  ):
1026    rule = (
1027        'tradefed_host_driven_device_test'
1028        if is_host_driven
1029        else 'tradefed_device_driven_test'
1030    )
1031
1032    return TestTarget(
1033        package_name,
1034        rule,
1035        {
1036            'name': name,
1037            'test': ModuleRef.for_info(info),
1038            'module_name': info['module_name'],
1039            'suites': set(info.get(constants.MODULE_COMPATIBILITY_SUITES, [])),
1040            'tradefed_deps': list(
1041                map(
1042                    ModuleRef.for_name, info.get(constants.MODULE_HOST_DEPS, [])
1043                )
1044            ),
1045            'tags': info.get(constants.MODULE_TEST_OPTIONS_TAGS, []),
1046        },
1047        TestTarget.DEVICE_TEST_PREREQUISITES,
1048    )
1049
1050  @staticmethod
1051  def create_tradefed_robolectric_test_target(
1052      name: str, package_name: str, info: Dict[str, Any], jdk_label: str
1053  ):
1054    return TestTarget(
1055        package_name,
1056        'tradefed_robolectric_test',
1057        {
1058            'name': name,
1059            'test': ModuleRef.for_info(info),
1060            'module_name': info['module_name'],
1061            'tags': info.get(constants.MODULE_TEST_OPTIONS_TAGS, []),
1062            'jdk': jdk_label,
1063        },
1064        TestTarget.DEVICELESS_TEST_PREREQUISITES,
1065    )
1066
1067  def __init__(
1068      self,
1069      package_name: str,
1070      rule_name: str,
1071      attributes: Dict[str, Any],
1072      prerequisites=frozenset(),
1073  ):
1074    self._attributes = attributes
1075    self._package_name = package_name
1076    self._rule_name = rule_name
1077    self._prerequisites = prerequisites
1078
1079  def name(self) -> str:
1080    return self._attributes['name']
1081
1082  def package_name(self) -> str:
1083    return self._package_name
1084
1085  def required_imports(self) -> Set[Import]:
1086    return {Import('//bazel/rules:tradefed_test.bzl', self._rule_name)}
1087
1088  def dependencies(self) -> List[ModuleRef]:
1089    prerequisite_refs = map(ModuleRef.for_name, self._prerequisites)
1090
1091    declared_dep_refs = []
1092    for value in self._attributes.values():
1093      if isinstance(value, Iterable):
1094        declared_dep_refs.extend(
1095            [dep for dep in value if isinstance(dep, ModuleRef)]
1096        )
1097      elif isinstance(value, ModuleRef):
1098        declared_dep_refs.append(value)
1099
1100    return declared_dep_refs + list(prerequisite_refs)
1101
1102  def write_to_build_file(self, f: IO):
1103    prebuilt_target_name = self._attributes['test'].target().qualified_name()
1104    writer = IndentWriter(f)
1105    build_file_writer = BuildFileWriter(writer)
1106
1107    writer.write_line(f'{self._rule_name}(')
1108
1109    with writer.indent():
1110      build_file_writer.write_string_attribute('name', self._attributes['name'])
1111
1112      build_file_writer.write_string_attribute(
1113          'module_name', self._attributes['module_name']
1114      )
1115
1116      build_file_writer.write_string_attribute('test', prebuilt_target_name)
1117
1118      build_file_writer.write_label_list_attribute(
1119          'tradefed_deps', self._attributes.get('tradefed_deps')
1120      )
1121
1122      build_file_writer.write_string_list_attribute(
1123          'suites', sorted(self._attributes.get('suites', []))
1124      )
1125
1126      build_file_writer.write_string_list_attribute(
1127          'tags', sorted(self._attributes.get('tags', []))
1128      )
1129
1130      build_file_writer.write_label_attribute(
1131          'jdk', self._attributes.get('jdk', None)
1132      )
1133
1134    writer.write_line(')')
1135
1136
1137def _read_robolectric_jdk_path(
1138    test_xml_config_template: pathlib.Path,
1139) -> pathlib.Path:
1140  if not test_xml_config_template.is_file():
1141    return None
1142
1143  xml_root = ET.parse(test_xml_config_template).getroot()
1144  option = xml_root.find(".//option[@name='java-folder']")
1145  jdk_path = pathlib.Path(option.get('value', ''))
1146
1147  if not jdk_path.is_relative_to('prebuilts/jdk'):
1148    raise ValueError(
1149        f'Failed to get "java-folder" from `{test_xml_config_template}`'
1150    )
1151
1152  return jdk_path
1153
1154
1155class BuildFileWriter:
1156  """Class for writing BUILD files."""
1157
1158  def __init__(self, underlying: IndentWriter):
1159    self._underlying = underlying
1160
1161  def write_string_attribute(self, attribute_name, value):
1162    if value is None:
1163      return
1164
1165    self._underlying.write_line(f'{attribute_name} = "{value}",')
1166
1167  def write_label_attribute(self, attribute_name: str, label_name: str):
1168    if label_name is None:
1169      return
1170
1171    self._underlying.write_line(f'{attribute_name} = "{label_name}",')
1172
1173  def write_string_list_attribute(self, attribute_name, values):
1174    if not values:
1175      return
1176
1177    self._underlying.write_line(f'{attribute_name} = [')
1178
1179    with self._underlying.indent():
1180      for value in values:
1181        self._underlying.write_line(f'"{value}",')
1182
1183    self._underlying.write_line('],')
1184
1185  def write_label_list_attribute(
1186      self, attribute_name: str, modules: List[ModuleRef]
1187  ):
1188    if not modules:
1189      return
1190
1191    self._underlying.write_line(f'{attribute_name} = [')
1192
1193    with self._underlying.indent():
1194      for label in sorted(set(m.target().qualified_name() for m in modules)):
1195        self._underlying.write_line(f'"{label}",')
1196
1197    self._underlying.write_line('],')
1198
1199  def write_glob_attribute(self, attribute_name: str, patterns: List[str]):
1200    self._underlying.write_line(f'{attribute_name} = glob([')
1201
1202    with self._underlying.indent():
1203      for pattern in patterns:
1204        self._underlying.write_line(f'"{pattern}",')
1205
1206    self._underlying.write_line(']),')
1207
1208
1209@dataclasses.dataclass(frozen=True)
1210class Dependencies:
1211  static_dep_refs: List[ModuleRef]
1212  runtime_dep_refs: List[ModuleRef]
1213  data_dep_refs: List[ModuleRef]
1214  device_data_dep_refs: List[ModuleRef]
1215
1216
1217class SoongPrebuiltTarget(Target):
1218  """Class for generating a Soong prebuilt target on disk."""
1219
1220  @staticmethod
1221  def create(
1222      gen: WorkspaceGenerator, info: Dict[str, Any], package_name: str = ''
1223  ):
1224    module_name = info['module_name']
1225
1226    configs = [
1227        Config('host', gen.host_out_path),
1228        Config('device', gen.resource_manager.get_product_out_file_path()),
1229    ]
1230
1231    installed_paths = get_module_installed_paths(
1232        info, gen.resource_manager.get_src_file_path()
1233    )
1234    config_files = group_paths_by_config(configs, installed_paths)
1235
1236    # For test modules, we only create symbolic link to the 'testcases'
1237    # directory since the information in module-info is not accurate.
1238    if gen.mod_info.is_tradefed_testable_module(info):
1239      config_files = {
1240          c: [c.out_path.joinpath(f'testcases/{module_name}')]
1241          for c in config_files.keys()
1242      }
1243
1244    enabled_features = gen.enabled_features
1245
1246    return SoongPrebuiltTarget(
1247        info,
1248        package_name,
1249        config_files,
1250        Dependencies(
1251            static_dep_refs=find_static_dep_refs(
1252                gen.mod_info,
1253                info,
1254                configs,
1255                gen.resource_manager.get_src_file_path(),
1256                enabled_features,
1257            ),
1258            runtime_dep_refs=find_runtime_dep_refs(
1259                gen.mod_info,
1260                info,
1261                configs,
1262                gen.resource_manager.get_src_file_path(),
1263                enabled_features,
1264            ),
1265            data_dep_refs=find_data_dep_refs(
1266                gen.mod_info,
1267                info,
1268                configs,
1269                gen.resource_manager.get_src_file_path(),
1270            ),
1271            device_data_dep_refs=find_device_data_dep_refs(gen, info),
1272        ),
1273        [
1274            c
1275            for c in configs
1276            if c.name
1277            in map(str.lower, info.get(constants.MODULE_SUPPORTED_VARIANTS, []))
1278        ],
1279    )
1280
1281  def __init__(
1282      self,
1283      info: Dict[str, Any],
1284      package_name: str,
1285      config_files: Dict[Config, List[pathlib.Path]],
1286      deps: Dependencies,
1287      supported_configs: List[Config],
1288  ):
1289    self._target_name = info[constants.MODULE_INFO_ID]
1290    self._module_name = info[constants.MODULE_NAME]
1291    self._package_name = package_name
1292    self.config_files = config_files
1293    self.deps = deps
1294    self.suites = info.get(constants.MODULE_COMPATIBILITY_SUITES, [])
1295    self._supported_configs = supported_configs
1296
1297  def name(self) -> str:
1298    return self._target_name
1299
1300  def package_name(self) -> str:
1301    return self._package_name
1302
1303  def required_imports(self) -> Set[Import]:
1304    return {
1305        Import('//bazel/rules:soong_prebuilt.bzl', self._rule_name()),
1306    }
1307
1308  @functools.lru_cache(maxsize=128)
1309  def supported_configs(self) -> Set[Config]:
1310    # We deduce the supported configs from the installed paths since the
1311    # build exports incorrect metadata for some module types such as
1312    # Robolectric. The information exported from the build is only used if
1313    # the module does not have any installed paths.
1314    # TODO(b/232929584): Remove this once all modules correctly export the
1315    #  supported variants.
1316    supported_configs = set(self.config_files.keys())
1317    if supported_configs:
1318      return supported_configs
1319
1320    return self._supported_configs
1321
1322  def dependencies(self) -> List[ModuleRef]:
1323    all_deps = set(self.deps.runtime_dep_refs)
1324    all_deps.update(self.deps.data_dep_refs)
1325    all_deps.update(self.deps.device_data_dep_refs)
1326    all_deps.update(self.deps.static_dep_refs)
1327    return list(all_deps)
1328
1329  def write_to_build_file(self, f: IO):
1330    writer = IndentWriter(f)
1331    build_file_writer = BuildFileWriter(writer)
1332
1333    writer.write_line(f'{self._rule_name()}(')
1334
1335    with writer.indent():
1336      writer.write_line(f'name = "{self._target_name}",')
1337      writer.write_line(f'module_name = "{self._module_name}",')
1338      self._write_files_attribute(writer)
1339      self._write_deps_attribute(
1340          writer, 'static_deps', self.deps.static_dep_refs
1341      )
1342      self._write_deps_attribute(
1343          writer, 'runtime_deps', self.deps.runtime_dep_refs
1344      )
1345      self._write_deps_attribute(writer, 'data', self.deps.data_dep_refs)
1346
1347      build_file_writer.write_label_list_attribute(
1348          'device_data', self.deps.device_data_dep_refs
1349      )
1350      build_file_writer.write_string_list_attribute(
1351          'suites', sorted(self.suites)
1352      )
1353
1354    writer.write_line(')')
1355
1356  def create_filesystem_layout(self, package_dir: pathlib.Path):
1357    prebuilts_dir = package_dir.joinpath(self._target_name)
1358    prebuilts_dir.mkdir()
1359
1360    for config, files in self.config_files.items():
1361      config_prebuilts_dir = prebuilts_dir.joinpath(config.name)
1362      config_prebuilts_dir.mkdir()
1363
1364      for f in files:
1365        rel_path = f.relative_to(config.out_path)
1366        symlink = config_prebuilts_dir.joinpath(rel_path)
1367        symlink.parent.mkdir(parents=True, exist_ok=True)
1368        symlink.symlink_to(f)
1369
1370  def _rule_name(self):
1371    return (
1372        'soong_prebuilt' if self.config_files else 'soong_uninstalled_prebuilt'
1373    )
1374
1375  def _write_files_attribute(self, writer: IndentWriter):
1376    if not self.config_files:
1377      return
1378
1379    writer.write('files = ')
1380    write_config_select(
1381        writer,
1382        self.config_files,
1383        lambda c, _: writer.write(
1384            f'glob(["{self._target_name}/{c.name}/**/*"])'
1385        ),
1386    )
1387    writer.write_line(',')
1388
1389  def _write_deps_attribute(self, writer, attribute_name, module_refs):
1390    config_deps = filter_configs(
1391        group_targets_by_config(r.target() for r in module_refs),
1392        self.supported_configs(),
1393    )
1394
1395    if not config_deps:
1396      return
1397
1398    for config in self.supported_configs():
1399      config_deps.setdefault(config, [])
1400
1401    writer.write(f'{attribute_name} = ')
1402    write_config_select(
1403        writer,
1404        config_deps,
1405        lambda _, targets: write_target_list(writer, targets),
1406    )
1407    writer.write_line(',')
1408
1409
1410def group_paths_by_config(
1411    configs: List[Config], paths: List[pathlib.Path]
1412) -> Dict[Config, List[pathlib.Path]]:
1413
1414  config_files = defaultdict(list)
1415
1416  for f in paths:
1417    matching_configs = [c for c in configs if _is_relative_to(f, c.out_path)]
1418
1419    if not matching_configs:
1420      continue
1421
1422    # The path can only appear in ANDROID_HOST_OUT for host target or
1423    # ANDROID_PRODUCT_OUT, but cannot appear in both.
1424    if len(matching_configs) > 1:
1425      raise ValueError(
1426          f'Installed path `{f}` is not in'
1427          ' ANDROID_HOST_OUT or ANDROID_PRODUCT_OUT'
1428      )
1429
1430    config_files[matching_configs[0]].append(f)
1431
1432  return config_files
1433
1434
1435def group_targets_by_config(
1436    targets: List[Target],
1437) -> Dict[Config, List[Target]]:
1438
1439  config_to_targets = defaultdict(list)
1440
1441  for target in targets:
1442    for config in target.supported_configs():
1443      config_to_targets[config].append(target)
1444
1445  return config_to_targets
1446
1447
1448def filter_configs(
1449    config_dict: Dict[Config, Any],
1450    configs: Set[Config],
1451) -> Dict[Config, Any]:
1452  return {k: v for (k, v) in config_dict.items() if k in configs}
1453
1454
1455def _is_relative_to(path1: pathlib.Path, path2: pathlib.Path) -> bool:
1456  """Return True if the path is relative to another path or False."""
1457  # Note that this implementation is required because Path.is_relative_to only
1458  # exists starting with Python 3.9.
1459  try:
1460    path1.relative_to(path2)
1461    return True
1462  except ValueError:
1463    return False
1464
1465
1466def get_module_installed_paths(
1467    info: Dict[str, Any], src_root_path: pathlib.Path
1468) -> List[pathlib.Path]:
1469
1470  # Install paths in module-info are usually relative to the Android
1471  # source root ${ANDROID_BUILD_TOP}. When the output directory is
1472  # customized by the user however, the install paths are absolute.
1473  def resolve(install_path_string):
1474    install_path = pathlib.Path(install_path_string)
1475    if not install_path.expanduser().is_absolute():
1476      return src_root_path.joinpath(install_path)
1477    return install_path
1478
1479  return map(resolve, info.get(constants.MODULE_INSTALLED, []))
1480
1481
1482def find_runtime_dep_refs(
1483    mod_info: module_info.ModuleInfo,
1484    info: module_info.Module,
1485    configs: List[Config],
1486    src_root_path: pathlib.Path,
1487    enabled_features: List[Features],
1488) -> List[ModuleRef]:
1489  """Return module references for runtime dependencies."""
1490
1491  # We don't use the `dependencies` module-info field for shared libraries
1492  # since it's ambiguous and could generate more targets and pull in more
1493  # dependencies than necessary. In particular, libraries that support both
1494  # static and dynamic linking could end up becoming runtime dependencies
1495  # even though the build specifies static linking. For example, if a target
1496  # 'T' is statically linked to 'U' which supports both variants, the latter
1497  # still appears as a dependency. Since we can't tell, this would result in
1498  # the shared library variant of 'U' being added on the library path.
1499  libs = set()
1500  libs.update(info.get(constants.MODULE_SHARED_LIBS, []))
1501  libs.update(info.get(constants.MODULE_RUNTIME_DEPS, []))
1502
1503  if Features.EXPERIMENTAL_JAVA_RUNTIME_DEPENDENCIES in enabled_features:
1504    libs.update(info.get(constants.MODULE_LIBS, []))
1505
1506  runtime_dep_refs = _find_module_refs(mod_info, configs, src_root_path, libs)
1507
1508  runtime_library_class = {'RLIB_LIBRARIES', 'DYLIB_LIBRARIES'}
1509  # We collect rlibs even though they are technically static libraries since
1510  # they could refer to dylibs which are required at runtime. Generating
1511  # Bazel targets for these intermediate modules keeps the generator simple
1512  # and preserves the shape (isomorphic) of the Soong structure making the
1513  # workspace easier to debug.
1514  for dep_name in info.get(constants.MODULE_DEPENDENCIES, []):
1515    dep_info = mod_info.get_module_info(dep_name)
1516    if not dep_info:
1517      continue
1518    if not runtime_library_class.intersection(
1519        dep_info.get(constants.MODULE_CLASS, [])
1520    ):
1521      continue
1522    runtime_dep_refs.append(ModuleRef.for_info(dep_info))
1523
1524  return runtime_dep_refs
1525
1526
1527def find_data_dep_refs(
1528    mod_info: module_info.ModuleInfo,
1529    info: module_info.Module,
1530    configs: List[Config],
1531    src_root_path: pathlib.Path,
1532) -> List[ModuleRef]:
1533  """Return module references for data dependencies."""
1534
1535  return _find_module_refs(
1536      mod_info, configs, src_root_path, info.get(constants.MODULE_DATA_DEPS, [])
1537  )
1538
1539
1540def find_device_data_dep_refs(
1541    gen: WorkspaceGenerator,
1542    info: module_info.Module,
1543) -> List[ModuleRef]:
1544  """Return module references for device data dependencies."""
1545
1546  return _find_module_refs(
1547      gen.mod_info,
1548      [Config('device', gen.resource_manager.get_product_out_file_path())],
1549      gen.resource_manager.get_src_file_path(),
1550      info.get(constants.MODULE_TARGET_DEPS, []),
1551  )
1552
1553
1554def find_static_dep_refs(
1555    mod_info: module_info.ModuleInfo,
1556    info: module_info.Module,
1557    configs: List[Config],
1558    src_root_path: pathlib.Path,
1559    enabled_features: List[Features],
1560) -> List[ModuleRef]:
1561  """Return module references for static libraries."""
1562
1563  if Features.EXPERIMENTAL_JAVA_RUNTIME_DEPENDENCIES not in enabled_features:
1564    return []
1565
1566  static_libs = set()
1567  static_libs.update(info.get(constants.MODULE_STATIC_LIBS, []))
1568  static_libs.update(info.get(constants.MODULE_STATIC_DEPS, []))
1569
1570  return _find_module_refs(mod_info, configs, src_root_path, static_libs)
1571
1572
1573def _find_module_refs(
1574    mod_info: module_info.ModuleInfo,
1575    configs: List[Config],
1576    src_root_path: pathlib.Path,
1577    module_names: List[str],
1578) -> List[ModuleRef]:
1579  """Return module references for modules."""
1580
1581  module_refs = []
1582
1583  for name in module_names:
1584    info = mod_info.get_module_info(name)
1585    if not info:
1586      continue
1587
1588    installed_paths = get_module_installed_paths(info, src_root_path)
1589    config_files = group_paths_by_config(configs, installed_paths)
1590    if not config_files:
1591      continue
1592
1593    module_refs.append(ModuleRef.for_info(info))
1594
1595  return module_refs
1596
1597
1598class IndentWriter:
1599
1600  def __init__(self, f: IO):
1601    self._file = f
1602    self._indent_level = 0
1603    self._indent_string = 4 * ' '
1604    self._indent_next = True
1605
1606  def write_line(self, text: str = ''):
1607    if text:
1608      self.write(text)
1609
1610    self._file.write('\n')
1611    self._indent_next = True
1612
1613  def write(self, text):
1614    if self._indent_next:
1615      self._file.write(self._indent_string * self._indent_level)
1616      self._indent_next = False
1617
1618    self._file.write(text)
1619
1620  @contextlib.contextmanager
1621  def indent(self):
1622    self._indent_level += 1
1623    yield
1624    self._indent_level -= 1
1625
1626
1627def write_config_select(
1628    writer: IndentWriter,
1629    config_dict: Dict[Config, Any],
1630    write_value_fn: Callable,
1631):
1632  writer.write_line('select({')
1633
1634  with writer.indent():
1635    for config, value in sorted(config_dict.items(), key=lambda c: c[0].name):
1636
1637      writer.write(f'"//bazel/rules:{config.name}": ')
1638      write_value_fn(config, value)
1639      writer.write_line(',')
1640
1641  writer.write('})')
1642
1643
1644def write_target_list(writer: IndentWriter, targets: List[Target]):
1645  writer.write_line('[')
1646
1647  with writer.indent():
1648    for label in sorted(set(t.qualified_name() for t in targets)):
1649      writer.write_line(f'"{label}",')
1650
1651  writer.write(']')
1652
1653
1654def _decorate_find_method(mod_info, finder_method_func, host, enabled_features):
1655  """A finder_method decorator to override TestInfo properties."""
1656
1657  def use_bazel_runner(finder_obj, test_id):
1658    test_infos = finder_method_func(finder_obj, test_id)
1659    if not test_infos:
1660      return test_infos
1661    for tinfo in test_infos:
1662      m_info = mod_info.get_module_info(tinfo.test_name)
1663
1664      # TODO(b/262200630): Refactor the duplicated logic in
1665      # _decorate_find_method() and _add_test_module_targets() to
1666      # determine whether a test should run with Atest Bazel Mode.
1667
1668      # Only enable modern Robolectric tests since those are the only ones
1669      # TF currently supports.
1670      if mod_info.is_modern_robolectric_test(m_info):
1671        if Features.EXPERIMENTAL_ROBOLECTRIC_TEST in enabled_features:
1672          tinfo.test_runner = BazelTestRunner.NAME
1673        continue
1674
1675      # Only run device-driven tests in Bazel mode when '--host' is not
1676      # specified and the feature is enabled.
1677      if not host and mod_info.is_device_driven_test(m_info):
1678        if Features.EXPERIMENTAL_DEVICE_DRIVEN_TEST in enabled_features:
1679          tinfo.test_runner = BazelTestRunner.NAME
1680        continue
1681
1682      if mod_info.is_suite_in_compatibility_suites(
1683          'host-unit-tests', m_info
1684      ) or (
1685          Features.EXPERIMENTAL_HOST_DRIVEN_TEST in enabled_features
1686          and mod_info.is_host_driven_test(m_info)
1687      ):
1688        tinfo.test_runner = BazelTestRunner.NAME
1689    return test_infos
1690
1691  return use_bazel_runner
1692
1693
1694def create_new_finder(
1695    mod_info: module_info.ModuleInfo,
1696    finder: test_finder_base.TestFinderBase,
1697    host: bool,
1698    enabled_features: List[Features] = None,
1699):
1700  """Create new test_finder_base.Finder with decorated find_method.
1701
1702  Args:
1703    mod_info: ModuleInfo object.
1704    finder: Test Finder class.
1705    host: Whether to run the host variant.
1706    enabled_features: List of enabled features.
1707
1708  Returns:
1709      List of ordered find methods.
1710  """
1711  return test_finder_base.Finder(
1712      finder.test_finder_instance,
1713      _decorate_find_method(
1714          mod_info, finder.find_method, host, enabled_features or []
1715      ),
1716      finder.finder_info,
1717  )
1718
1719
1720class RunCommandError(subprocess.CalledProcessError):
1721  """CalledProcessError but including debug information when it fails."""
1722
1723  def __str__(self):
1724    return f'{super().__str__()}\nstdout={self.stdout}\n\nstderr={self.stderr}'
1725
1726
1727def default_run_command(args: List[str], cwd: pathlib.Path) -> str:
1728  result = subprocess.run(
1729      args=args,
1730      cwd=cwd,
1731      text=True,
1732      capture_output=True,
1733      check=False,
1734  )
1735  if result.returncode:
1736    # Provide a more detailed log message including stdout and stderr.
1737    raise RunCommandError(
1738        result.returncode, result.args, result.stdout, result.stderr
1739    )
1740  return result.stdout
1741
1742
1743@dataclasses.dataclass
1744class BuildMetadata:
1745  build_branch: str
1746  build_target: str
1747
1748
1749class BazelTestRunner(trb.TestRunnerBase):
1750  """Bazel Test Runner class."""
1751
1752  NAME = 'BazelTestRunner'
1753  EXECUTABLE = 'none'
1754
1755  # pylint: disable=redefined-outer-name
1756  # pylint: disable=too-many-arguments
1757  def __init__(
1758      self,
1759      results_dir,
1760      mod_info: module_info.ModuleInfo,
1761      extra_args: Dict[str, Any] = None,
1762      src_top: pathlib.Path = None,
1763      workspace_path: pathlib.Path = None,
1764      run_command: Callable = default_run_command,
1765      build_metadata: BuildMetadata = None,
1766      env: Dict[str, str] = None,
1767      generate_workspace_fn: Callable = generate_bazel_workspace,
1768      enabled_features: Set[str] = None,
1769      **kwargs,
1770  ):
1771    super().__init__(results_dir, **kwargs)
1772    self.mod_info = mod_info
1773    self.src_top = src_top or pathlib.Path(
1774        os.environ.get(constants.ANDROID_BUILD_TOP)
1775    )
1776    self.starlark_file = _get_resource_root().joinpath(
1777        'format_as_soong_module_name.cquery'
1778    )
1779
1780    self.bazel_workspace = workspace_path or get_bazel_workspace_dir()
1781    self.bazel_binary = self.bazel_workspace.joinpath('bazel.sh')
1782    self.run_command = run_command
1783    self._extra_args = extra_args or {}
1784    self.build_metadata = build_metadata or get_default_build_metadata()
1785    self.env = env or os.environ
1786    self._generate_workspace_fn = generate_workspace_fn
1787    self._enabled_features = (
1788        enabled_features
1789        if enabled_features is not None
1790        else atest_configs.GLOBAL_ARGS.bazel_mode_features
1791    )
1792
1793  # pylint: disable=unused-argument
1794  def run_tests(self, test_infos, extra_args, reporter):
1795    """Run the list of test_infos.
1796
1797    Args:
1798        test_infos: List of TestInfo.
1799        extra_args: Dict of extra args to add to test run.
1800        reporter: An instance of result_report.ResultReporter.
1801    """
1802    ret_code = ExitCode.SUCCESS
1803
1804    try:
1805      run_cmds = self.generate_run_commands(test_infos, extra_args)
1806    except AbortRunException as e:
1807      atest_utils.colorful_print(f'Stop running test(s): {e}', constants.RED)
1808      return ExitCode.ERROR
1809
1810    for run_cmd in run_cmds:
1811      subproc = self.run(run_cmd, output_to_stdout=True)
1812      ret_code |= self.wait_for_subprocess(subproc)
1813
1814    self.organize_test_logs(test_infos)
1815
1816    return ret_code
1817
1818  def organize_test_logs(self, test_infos: List[test_info.TestInfo]):
1819    for t_info in test_infos:
1820      test_output_dir, package_name, target_suffix = (
1821          self.retrieve_test_output_info(t_info)
1822      )
1823      if test_output_dir.joinpath(TEST_OUTPUT_ZIP_NAME).exists():
1824        # TEST_OUTPUT_ZIP file exist when BES uploading is enabled.
1825        # Showing the BES link to users instead of the local log.
1826        continue
1827
1828      # AtestExecutionInfo will find all log files in 'results_dir/log'
1829      # directory and generate an HTML file to display to users when
1830      # 'results_dir/log' directory exist.
1831      log_path = pathlib.Path(self.results_dir).joinpath(
1832          'log', f'{package_name}', f'{t_info.test_name}_{target_suffix}'
1833      )
1834      log_path.parent.mkdir(parents=True, exist_ok=True)
1835      if not log_path.is_symlink():
1836        log_path.symlink_to(test_output_dir)
1837
1838  def _get_feature_config_or_warn(self, feature, env_var_name):
1839    feature_config = self.env.get(env_var_name)
1840    if not feature_config:
1841      atest_utils.print_and_log_warning(
1842          'Ignoring `%s` because the `%s` environment variable is not set.',
1843          # pylint: disable=no-member
1844          feature,
1845          env_var_name,
1846      )
1847    return feature_config
1848
1849  def _get_bes_publish_args(self, feature: Features) -> List[str]:
1850    bes_publish_config = self._get_feature_config_or_warn(
1851        feature, 'ATEST_BAZEL_BES_PUBLISH_CONFIG'
1852    )
1853
1854    if not bes_publish_config:
1855      return []
1856
1857    branch = self.build_metadata.build_branch
1858    target = self.build_metadata.build_target
1859
1860    return [
1861        f'--config={bes_publish_config}',
1862        f'--build_metadata=ab_branch={branch}',
1863        f'--build_metadata=ab_target={target}',
1864    ]
1865
1866  def _get_remote_args(self, feature):
1867    remote_config = self._get_feature_config_or_warn(
1868        feature, 'ATEST_BAZEL_REMOTE_CONFIG'
1869    )
1870    if not remote_config:
1871      return []
1872    return [f'--config={remote_config}']
1873
1874  def _get_remote_avd_args(self, feature):
1875    remote_avd_config = self._get_feature_config_or_warn(
1876        feature, 'ATEST_BAZEL_REMOTE_AVD_CONFIG'
1877    )
1878    if not remote_avd_config:
1879      raise ValueError(
1880          'Cannot run remote device test because '
1881          'ATEST_BAZEL_REMOTE_AVD_CONFIG '
1882          'environment variable is not set.'
1883      )
1884    return [f'--config={remote_avd_config}']
1885
1886  def host_env_check(self):
1887    """Check that host env has everything we need.
1888
1889    We actually can assume the host env is fine because we have the same
1890    requirements that atest has. Update this to check for android env vars
1891    if that changes.
1892    """
1893
1894  def get_test_runner_build_reqs(self, test_infos) -> Set[str]:
1895    if not test_infos:
1896      return set()
1897
1898    self._generate_workspace_fn(
1899        self.mod_info,
1900        self._enabled_features,
1901    )
1902
1903    deps_expression = ' + '.join(
1904        sorted(self.test_info_target_label(i) for i in test_infos)
1905    )
1906
1907    with tempfile.NamedTemporaryFile() as query_file:
1908      with open(query_file.name, 'w', encoding='utf-8') as _query_file:
1909        _query_file.write(f'deps(tests({deps_expression}))')
1910
1911      query_args = [
1912          str(self.bazel_binary),
1913          'cquery',
1914          f'--query_file={query_file.name}',
1915          '--output=starlark',
1916          f'--starlark:file={self.starlark_file}',
1917      ]
1918
1919      output = self.run_command(query_args, self.bazel_workspace)
1920
1921    targets = set()
1922    robolectric_tests = set(
1923        filter(
1924            self._is_robolectric_test_suite,
1925            [test.test_name for test in test_infos],
1926        )
1927    )
1928
1929    modules_to_variant = _parse_cquery_output(output)
1930
1931    for module, variants in modules_to_variant.items():
1932
1933      # Skip specifying the build variant for Robolectric test modules
1934      # since they are special. Soong builds them with the `target`
1935      # variant although are installed as 'host' modules.
1936      if module in robolectric_tests:
1937        targets.add(module)
1938        continue
1939
1940      targets.add(_soong_target_for_variants(module, variants))
1941
1942    return targets
1943
1944  def _is_robolectric_test_suite(self, module_name: str) -> bool:
1945    return self.mod_info.is_robolectric_test_suite(
1946        self.mod_info.get_module_info(module_name)
1947    )
1948
1949  def test_info_target_label(self, test: test_info.TestInfo) -> str:
1950    module_name = test.test_name
1951    info = self.mod_info.get_module_info(module_name)
1952    package_name = info.get(constants.MODULE_PATH)[0]
1953    target_suffix = self.get_target_suffix(info)
1954
1955    return f'//{package_name}:{module_name}_{target_suffix}'
1956
1957  def retrieve_test_output_info(
1958      self, test_info: test_info.TestInfo
1959  ) -> Tuple[pathlib.Path, str, str]:
1960    """Return test output information.
1961
1962    Args:
1963        test_info (test_info.TestInfo): Information about the test.
1964
1965    Returns:
1966        Tuple[pathlib.Path, str, str]: A tuple containing the following
1967        elements:
1968            - test_output_dir (pathlib.Path): Absolute path of the test output
1969                folder.
1970            - package_name (str): Name of the package.
1971            - target_suffix (str): Target suffix.
1972    """
1973    module_name = test_info.test_name
1974    info = self.mod_info.get_module_info(module_name)
1975    package_name = info.get(constants.MODULE_PATH)[0]
1976    target_suffix = self.get_target_suffix(info)
1977
1978    test_output_dir = pathlib.Path(
1979        self.bazel_workspace,
1980        BAZEL_TEST_LOGS_DIR_NAME,
1981        package_name,
1982        f'{module_name}_{target_suffix}',
1983        TEST_OUTPUT_DIR_NAME,
1984    )
1985
1986    return test_output_dir, package_name, target_suffix
1987
1988  def get_target_suffix(self, info: Dict[str, Any]) -> str:
1989    """Return 'host' or 'device' accordingly to the variant of the test."""
1990    if not self._extra_args.get(
1991        constants.HOST, False
1992    ) and self.mod_info.is_device_driven_test(info):
1993      return 'device'
1994    return 'host'
1995
1996  @staticmethod
1997  def _get_bazel_feature_args(
1998      feature: Features, extra_args: Dict[str, Any], generator: Callable
1999  ) -> List[str]:
2000    if feature not in extra_args.get('BAZEL_MODE_FEATURES', []):
2001      return []
2002    return generator(feature)
2003
2004  # pylint: disable=unused-argument
2005  def generate_run_commands(self, test_infos, extra_args, port=None):
2006    """Generate a list of run commands from TestInfos.
2007
2008    Args:
2009        test_infos: A set of TestInfo instances.
2010        extra_args: A Dict of extra args to append.
2011        port: Optional. An int of the port number to send events to.
2012
2013    Returns:
2014        A list of run commands to run the tests.
2015    """
2016    startup_options = ''
2017    bazelrc = self.env.get('ATEST_BAZELRC')
2018
2019    if bazelrc:
2020      startup_options = f'--bazelrc={bazelrc}'
2021
2022    target_patterns = ' '.join(
2023        self.test_info_target_label(i) for i in test_infos
2024    )
2025
2026    bazel_args = parse_args(test_infos, extra_args)
2027
2028    # If BES is not enabled, use the option of
2029    # '--nozip_undeclared_test_outputs' to not compress the test outputs.
2030    # And the URL of test outputs will be printed in terminal.
2031    bazel_args.extend(
2032        self._get_bazel_feature_args(
2033            Features.EXPERIMENTAL_BES_PUBLISH,
2034            extra_args,
2035            self._get_bes_publish_args,
2036        )
2037        or ['--nozip_undeclared_test_outputs']
2038    )
2039    bazel_args.extend(
2040        self._get_bazel_feature_args(
2041            Features.EXPERIMENTAL_REMOTE, extra_args, self._get_remote_args
2042        )
2043    )
2044    bazel_args.extend(
2045        self._get_bazel_feature_args(
2046            Features.EXPERIMENTAL_REMOTE_AVD,
2047            extra_args,
2048            self._get_remote_avd_args,
2049        )
2050    )
2051
2052    # This is an alternative to shlex.join that doesn't exist in Python
2053    # versions < 3.8.
2054    bazel_args_str = ' '.join(shlex.quote(arg) for arg in bazel_args)
2055
2056    # Use 'cd' instead of setting the working directory in the subprocess
2057    # call for a working --dry-run command that users can run.
2058    return [
2059        f'cd {self.bazel_workspace} && '
2060        f'{self.bazel_binary} {startup_options} '
2061        f'test {target_patterns} {bazel_args_str}'
2062    ]
2063
2064
2065def parse_args(
2066    test_infos: List[test_info.TestInfo], extra_args: Dict[str, Any]
2067) -> Dict[str, Any]:
2068  """Parse commandline args and passes supported args to bazel.
2069
2070  Args:
2071      test_infos: A set of TestInfo instances.
2072      extra_args: A Dict of extra args to append.
2073
2074  Returns:
2075      A list of args to append to the run command.
2076  """
2077
2078  args_to_append = []
2079  # Make a copy of the `extra_args` dict to avoid modifying it for other
2080  # Atest runners.
2081  extra_args_copy = extra_args.copy()
2082
2083  # Remove the `--host` flag since we already pass that in the rule's
2084  # implementation.
2085  extra_args_copy.pop(constants.HOST, None)
2086
2087  # Remove the serial arg since Bazel mode does not support device tests and
2088  # the serial / -s arg conflicts with the TF null device option specified in
2089  # the rule implementation (-n).
2090  extra_args_copy.pop(constants.SERIAL, None)
2091
2092  # Map args to their native Bazel counterparts.
2093  for arg in _SUPPORTED_BAZEL_ARGS:
2094    if arg not in extra_args_copy:
2095      continue
2096    args_to_append.extend(_map_to_bazel_args(arg, extra_args_copy[arg]))
2097    # Remove the argument since we already mapped it to a Bazel option
2098    # and no longer need it mapped to a Tradefed argument below.
2099    del extra_args_copy[arg]
2100
2101  # TODO(b/215461642): Store the extra_args in the top-level object so
2102  # that we don't have to re-parse the extra args to get BAZEL_ARG again.
2103  tf_args, _ = tfr.extra_args_to_tf_args(extra_args_copy)
2104
2105  # Add ATest include filter argument to allow testcase filtering.
2106  tf_args.extend(tfr.get_include_filter(test_infos))
2107
2108  args_to_append.extend([f'--test_arg={i}' for i in tf_args])
2109
2110  # Disable test result caching when wait-for-debugger flag is set.
2111  if '--wait-for-debugger' in tf_args:
2112    # Remove the --cache_test_results flag if it's already set.
2113    args_to_append = [
2114        arg
2115        for arg in args_to_append
2116        if not arg.startswith('--cache_test_results')
2117    ]
2118    args_to_append.append('--cache_test_results=no')
2119
2120  # Default to --test_output=errors unless specified otherwise
2121  if not any(arg.startswith('--test_output=') for arg in args_to_append):
2122    args_to_append.append('--test_output=errors')
2123
2124  # Default to --test_summary=detailed unless specified otherwise, or if the
2125  # feature is disabled
2126  if not any(arg.startswith('--test_summary=') for arg in args_to_append) and (
2127      Features.NO_BAZEL_DETAILED_SUMMARY
2128      not in extra_args.get('BAZEL_MODE_FEATURES', [])
2129  ):
2130    args_to_append.append('--test_summary=detailed')
2131
2132  return args_to_append
2133
2134
2135def _map_to_bazel_args(arg: str, arg_value: Any) -> List[str]:
2136  return (
2137      _SUPPORTED_BAZEL_ARGS[arg](arg_value)
2138      if arg in _SUPPORTED_BAZEL_ARGS
2139      else []
2140  )
2141
2142
2143def _parse_cquery_output(output: str) -> Dict[str, Set[str]]:
2144  module_to_build_variants = defaultdict(set)
2145
2146  for line in filter(bool, map(str.strip, output.splitlines())):
2147    module_name, build_variant = line.split(':')
2148    module_to_build_variants[module_name].add(build_variant)
2149
2150  return module_to_build_variants
2151
2152
2153def _soong_target_for_variants(
2154    module_name: str, build_variants: Set[str]
2155) -> str:
2156
2157  if not build_variants:
2158    raise ValueError(
2159        f'Missing the build variants for module {module_name} in cquery output!'
2160    )
2161
2162  if len(build_variants) > 1:
2163    return module_name
2164
2165  return f'{module_name}-{_CONFIG_TO_VARIANT[list(build_variants)[0]]}'
2166