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._symlink( 721 src='tools/asuite/atest/bazel/resources/bazel.sh', target='bazel.sh' 722 ) 723 self._symlink( 724 src='prebuilts/jdk/jdk17/BUILD.bazel', 725 target='prebuilts/jdk/jdk17/BUILD.bazel', 726 ) 727 self._symlink( 728 src='prebuilts/jdk/jdk17/linux-x86', 729 target='prebuilts/jdk/jdk17/linux-x86', 730 ) 731 self._symlink( 732 src='prebuilts/bazel/linux-x86_64/bazel', 733 target='prebuilts/bazel/linux-x86_64/bazel', 734 ) 735 736 def _add_workspace_resource(self, src, dst): 737 """Add resource to the given destination in workspace. 738 739 Args: 740 src: A string of a relative path to root of Bazel artifacts. This is the 741 source file/dir path that will be added to workspace. 742 dst: A string of a relative path to workspace root. This is the 743 destination file/dir path where the artifacts will be added. 744 """ 745 src = self.resource_manager.get_resource_file_path(src, True) 746 dst = self.workspace_out_path.joinpath(dst) 747 dst.parent.mkdir(parents=True, exist_ok=True) 748 749 if src.is_file(): 750 shutil.copy(src, dst) 751 else: 752 shutil.copytree(src, dst, ignore=shutil.ignore_patterns('__init__.py')) 753 754 def _create_constants_file(self): 755 def variable_name(target_name): 756 return re.sub(r'[.-]', '_', target_name) + '_label' 757 758 targets = [] 759 seen = set() 760 761 for module_name in TestTarget.DEVICELESS_TEST_PREREQUISITES.union( 762 TestTarget.DEVICE_TEST_PREREQUISITES 763 ): 764 info = self.mod_info.get_module_info(module_name) 765 target = self._add_prebuilt_target(info) 766 self._resolve_dependencies(target, seen) 767 targets.append(target) 768 769 with self.workspace_out_path.joinpath('constants.bzl').open('w') as f: 770 writer = IndentWriter(f) 771 for target in targets: 772 writer.write_line( 773 '%s = "%s"' 774 % (variable_name(target.name()), target.qualified_name()) 775 ) 776 777 def _link_required_src_file_path(self, path): 778 if not self.resource_manager.get_src_file_path(path).exists(): 779 raise RuntimeError(f'Path `{path}` does not exist in source tree.') 780 781 self._symlink(src=path, target=path) 782 783 784@functools.cache 785def _get_resource_root() -> pathlib.Path: 786 tmp_resource_dir = pathlib.Path(tempfile.mkdtemp()) 787 atexit.register(lambda: shutil.rmtree(tmp_resource_dir)) 788 789 def _extract_resources( 790 resource_path: pathlib.Path, 791 dst: pathlib.Path, 792 ignore_file_names: list[str] = None, 793 ): 794 resource = importlib.resources.files(resource_path.as_posix()) 795 dst.mkdir(parents=True, exist_ok=True) 796 for child in resource.iterdir(): 797 if child.is_file(): 798 if child.name in ignore_file_names: 799 continue 800 with importlib.resources.as_file(child) as child_file: 801 shutil.copy(child_file, dst.joinpath(child.name)) 802 elif child.is_dir(): 803 _extract_resources( 804 resource_path.joinpath(child.name), 805 dst.joinpath(child.name), 806 ignore_file_names, 807 ) 808 else: 809 atest_utils.print_and_log_warning( 810 'Ignoring unknown resource: %s', child 811 ) 812 813 try: 814 _extract_resources( 815 pathlib.Path('atest/bazel/resources'), 816 tmp_resource_dir, 817 ignore_file_names=['__init__.py'], 818 ) 819 except ModuleNotFoundError as e: 820 logging.debug( 821 'Bazel resource not found from package path, possible due to running' 822 ' atest from source. Returning resource source path instead: %s', 823 e, 824 ) 825 return pathlib.Path(os.path.dirname(__file__)).joinpath('bazel/resources') 826 827 return tmp_resource_dir 828 829 830class Package: 831 """Class for generating an entire Package on disk.""" 832 833 def __init__(self, path: str): 834 self.path = path 835 self.imports = defaultdict(set) 836 self.name_to_target = OrderedDict() 837 838 def add_target(self, target): 839 target_name = target.name() 840 841 if target_name in self.name_to_target: 842 raise ValueError( 843 f'Cannot add target `{target_name}` which already' 844 f' exists in package `{self.path}`' 845 ) 846 847 self.name_to_target[target_name] = target 848 849 for i in target.required_imports(): 850 self.imports[i.bzl_package].add(i.symbol) 851 852 def generate(self, workspace_out_path: pathlib.Path): 853 package_dir = workspace_out_path.joinpath(self.path) 854 package_dir.mkdir(parents=True, exist_ok=True) 855 856 self._create_filesystem_layout(package_dir) 857 self._write_build_file(package_dir) 858 859 def _create_filesystem_layout(self, package_dir: pathlib.Path): 860 for target in self.name_to_target.values(): 861 target.create_filesystem_layout(package_dir) 862 863 def _write_build_file(self, package_dir: pathlib.Path): 864 with package_dir.joinpath('BUILD.bazel').open('w') as f: 865 f.write('package(default_visibility = ["//visibility:public"])\n') 866 f.write('\n') 867 868 for bzl_package, symbols in sorted(self.imports.items()): 869 symbols_text = ', '.join('"%s"' % s for s in sorted(symbols)) 870 f.write(f'load("{bzl_package}", {symbols_text})\n') 871 872 for target in self.name_to_target.values(): 873 f.write('\n') 874 target.write_to_build_file(f) 875 876 def get_target(self, target_name: str) -> Target: 877 return self.name_to_target.get(target_name, None) 878 879 880@dataclasses.dataclass(frozen=True) 881class Import: 882 bzl_package: str 883 symbol: str 884 885 886@dataclasses.dataclass(frozen=True) 887class Config: 888 name: str 889 out_path: pathlib.Path 890 891 892class ModuleRef: 893 894 @staticmethod 895 def for_info(info) -> ModuleRef: 896 return ModuleRef(info=info) 897 898 @staticmethod 899 def for_name(name) -> ModuleRef: 900 return ModuleRef(name=name) 901 902 def __init__(self, info=None, name=None): 903 self.info = info 904 self.name = name 905 self._target = None 906 907 def target(self) -> Target: 908 if not self._target: 909 target_name = self.info[constants.MODULE_INFO_ID] 910 raise ValueError(f'Target not set for ref `{target_name}`') 911 912 return self._target 913 914 def set(self, target): 915 self._target = target 916 917 918class Target(ABC): 919 """Abstract class for a Bazel target.""" 920 921 @abstractmethod 922 def name(self) -> str: 923 pass 924 925 def package_name(self) -> str: 926 pass 927 928 def qualified_name(self) -> str: 929 return f'//{self.package_name()}:{self.name()}' 930 931 def required_imports(self) -> Set[Import]: 932 return set() 933 934 def supported_configs(self) -> Set[Config]: 935 return set() 936 937 def dependencies(self) -> List[ModuleRef]: 938 return [] 939 940 def write_to_build_file(self, f: IO): 941 pass 942 943 def create_filesystem_layout(self, package_dir: pathlib.Path): 944 pass 945 946 947class FilegroupTarget(Target): 948 949 def __init__( 950 self, package_name: str, target_name: str, srcs_root: pathlib.Path 951 ): 952 self._package_name = package_name 953 self._target_name = target_name 954 self._srcs_root = srcs_root 955 956 def name(self) -> str: 957 return self._target_name 958 959 def package_name(self) -> str: 960 return self._package_name 961 962 def write_to_build_file(self, f: IO): 963 writer = IndentWriter(f) 964 build_file_writer = BuildFileWriter(writer) 965 966 writer.write_line('filegroup(') 967 968 with writer.indent(): 969 build_file_writer.write_string_attribute('name', self._target_name) 970 build_file_writer.write_glob_attribute( 971 'srcs', [f'{self._target_name}_files/**'] 972 ) 973 974 writer.write_line(')') 975 976 def create_filesystem_layout(self, package_dir: pathlib.Path): 977 symlink = package_dir.joinpath(f'{self._target_name}_files') 978 symlink.symlink_to(self._srcs_root) 979 980 981class TestTarget(Target): 982 """Class for generating a test target.""" 983 984 DEVICELESS_TEST_PREREQUISITES = frozenset({ 985 'adb', 986 'atest-tradefed', 987 'atest_script_help.sh', 988 'atest_tradefed.sh', 989 'tradefed', 990 'tradefed-test-framework', 991 'bazel-result-reporter', 992 }) 993 994 DEVICE_TEST_PREREQUISITES = frozenset( 995 DEVICELESS_TEST_PREREQUISITES.union( 996 frozenset({ 997 'aapt', 998 'aapt2', 999 'compatibility-tradefed', 1000 'vts-core-tradefed-harness', 1001 }) 1002 ) 1003 ) 1004 1005 @staticmethod 1006 def create_deviceless_test_target( 1007 name: str, package_name: str, info: Dict[str, Any] 1008 ): 1009 return TestTarget( 1010 package_name, 1011 'tradefed_deviceless_test', 1012 { 1013 'name': name, 1014 'test': ModuleRef.for_info(info), 1015 'module_name': info['module_name'], 1016 'tags': info.get(constants.MODULE_TEST_OPTIONS_TAGS, []), 1017 }, 1018 TestTarget.DEVICELESS_TEST_PREREQUISITES, 1019 ) 1020 1021 @staticmethod 1022 def create_device_test_target( 1023 name: str, package_name: str, info: Dict[str, Any], is_host_driven: bool 1024 ): 1025 rule = ( 1026 'tradefed_host_driven_device_test' 1027 if is_host_driven 1028 else 'tradefed_device_driven_test' 1029 ) 1030 1031 return TestTarget( 1032 package_name, 1033 rule, 1034 { 1035 'name': name, 1036 'test': ModuleRef.for_info(info), 1037 'module_name': info['module_name'], 1038 'suites': set(info.get(constants.MODULE_COMPATIBILITY_SUITES, [])), 1039 'tradefed_deps': list( 1040 map( 1041 ModuleRef.for_name, info.get(constants.MODULE_HOST_DEPS, []) 1042 ) 1043 ), 1044 'tags': info.get(constants.MODULE_TEST_OPTIONS_TAGS, []), 1045 }, 1046 TestTarget.DEVICE_TEST_PREREQUISITES, 1047 ) 1048 1049 @staticmethod 1050 def create_tradefed_robolectric_test_target( 1051 name: str, package_name: str, info: Dict[str, Any], jdk_label: str 1052 ): 1053 return TestTarget( 1054 package_name, 1055 'tradefed_robolectric_test', 1056 { 1057 'name': name, 1058 'test': ModuleRef.for_info(info), 1059 'module_name': info['module_name'], 1060 'tags': info.get(constants.MODULE_TEST_OPTIONS_TAGS, []), 1061 'jdk': jdk_label, 1062 }, 1063 TestTarget.DEVICELESS_TEST_PREREQUISITES, 1064 ) 1065 1066 def __init__( 1067 self, 1068 package_name: str, 1069 rule_name: str, 1070 attributes: Dict[str, Any], 1071 prerequisites=frozenset(), 1072 ): 1073 self._attributes = attributes 1074 self._package_name = package_name 1075 self._rule_name = rule_name 1076 self._prerequisites = prerequisites 1077 1078 def name(self) -> str: 1079 return self._attributes['name'] 1080 1081 def package_name(self) -> str: 1082 return self._package_name 1083 1084 def required_imports(self) -> Set[Import]: 1085 return {Import('//bazel/rules:tradefed_test.bzl', self._rule_name)} 1086 1087 def dependencies(self) -> List[ModuleRef]: 1088 prerequisite_refs = map(ModuleRef.for_name, self._prerequisites) 1089 1090 declared_dep_refs = [] 1091 for value in self._attributes.values(): 1092 if isinstance(value, Iterable): 1093 declared_dep_refs.extend( 1094 [dep for dep in value if isinstance(dep, ModuleRef)] 1095 ) 1096 elif isinstance(value, ModuleRef): 1097 declared_dep_refs.append(value) 1098 1099 return declared_dep_refs + list(prerequisite_refs) 1100 1101 def write_to_build_file(self, f: IO): 1102 prebuilt_target_name = self._attributes['test'].target().qualified_name() 1103 writer = IndentWriter(f) 1104 build_file_writer = BuildFileWriter(writer) 1105 1106 writer.write_line(f'{self._rule_name}(') 1107 1108 with writer.indent(): 1109 build_file_writer.write_string_attribute('name', self._attributes['name']) 1110 1111 build_file_writer.write_string_attribute( 1112 'module_name', self._attributes['module_name'] 1113 ) 1114 1115 build_file_writer.write_string_attribute('test', prebuilt_target_name) 1116 1117 build_file_writer.write_label_list_attribute( 1118 'tradefed_deps', self._attributes.get('tradefed_deps') 1119 ) 1120 1121 build_file_writer.write_string_list_attribute( 1122 'suites', sorted(self._attributes.get('suites', [])) 1123 ) 1124 1125 build_file_writer.write_string_list_attribute( 1126 'tags', sorted(self._attributes.get('tags', [])) 1127 ) 1128 1129 build_file_writer.write_label_attribute( 1130 'jdk', self._attributes.get('jdk', None) 1131 ) 1132 1133 writer.write_line(')') 1134 1135 1136def _read_robolectric_jdk_path( 1137 test_xml_config_template: pathlib.Path, 1138) -> pathlib.Path: 1139 if not test_xml_config_template.is_file(): 1140 return None 1141 1142 xml_root = ET.parse(test_xml_config_template).getroot() 1143 option = xml_root.find(".//option[@name='java-folder']") 1144 jdk_path = pathlib.Path(option.get('value', '')) 1145 1146 if not jdk_path.is_relative_to('prebuilts/jdk'): 1147 raise ValueError( 1148 f'Failed to get "java-folder" from `{test_xml_config_template}`' 1149 ) 1150 1151 return jdk_path 1152 1153 1154class BuildFileWriter: 1155 """Class for writing BUILD files.""" 1156 1157 def __init__(self, underlying: IndentWriter): 1158 self._underlying = underlying 1159 1160 def write_string_attribute(self, attribute_name, value): 1161 if value is None: 1162 return 1163 1164 self._underlying.write_line(f'{attribute_name} = "{value}",') 1165 1166 def write_label_attribute(self, attribute_name: str, label_name: str): 1167 if label_name is None: 1168 return 1169 1170 self._underlying.write_line(f'{attribute_name} = "{label_name}",') 1171 1172 def write_string_list_attribute(self, attribute_name, values): 1173 if not values: 1174 return 1175 1176 self._underlying.write_line(f'{attribute_name} = [') 1177 1178 with self._underlying.indent(): 1179 for value in values: 1180 self._underlying.write_line(f'"{value}",') 1181 1182 self._underlying.write_line('],') 1183 1184 def write_label_list_attribute( 1185 self, attribute_name: str, modules: List[ModuleRef] 1186 ): 1187 if not modules: 1188 return 1189 1190 self._underlying.write_line(f'{attribute_name} = [') 1191 1192 with self._underlying.indent(): 1193 for label in sorted(set(m.target().qualified_name() for m in modules)): 1194 self._underlying.write_line(f'"{label}",') 1195 1196 self._underlying.write_line('],') 1197 1198 def write_glob_attribute(self, attribute_name: str, patterns: List[str]): 1199 self._underlying.write_line(f'{attribute_name} = glob([') 1200 1201 with self._underlying.indent(): 1202 for pattern in patterns: 1203 self._underlying.write_line(f'"{pattern}",') 1204 1205 self._underlying.write_line(']),') 1206 1207 1208@dataclasses.dataclass(frozen=True) 1209class Dependencies: 1210 static_dep_refs: List[ModuleRef] 1211 runtime_dep_refs: List[ModuleRef] 1212 data_dep_refs: List[ModuleRef] 1213 device_data_dep_refs: List[ModuleRef] 1214 1215 1216class SoongPrebuiltTarget(Target): 1217 """Class for generating a Soong prebuilt target on disk.""" 1218 1219 @staticmethod 1220 def create( 1221 gen: WorkspaceGenerator, info: Dict[str, Any], package_name: str = '' 1222 ): 1223 module_name = info['module_name'] 1224 1225 configs = [ 1226 Config('host', gen.host_out_path), 1227 Config('device', gen.resource_manager.get_product_out_file_path()), 1228 ] 1229 1230 installed_paths = get_module_installed_paths( 1231 info, gen.resource_manager.get_src_file_path() 1232 ) 1233 config_files = group_paths_by_config(configs, installed_paths) 1234 1235 # For test modules, we only create symbolic link to the 'testcases' 1236 # directory since the information in module-info is not accurate. 1237 if gen.mod_info.is_tradefed_testable_module(info): 1238 config_files = { 1239 c: [c.out_path.joinpath(f'testcases/{module_name}')] 1240 for c in config_files.keys() 1241 } 1242 1243 enabled_features = gen.enabled_features 1244 1245 return SoongPrebuiltTarget( 1246 info, 1247 package_name, 1248 config_files, 1249 Dependencies( 1250 static_dep_refs=find_static_dep_refs( 1251 gen.mod_info, 1252 info, 1253 configs, 1254 gen.resource_manager.get_src_file_path(), 1255 enabled_features, 1256 ), 1257 runtime_dep_refs=find_runtime_dep_refs( 1258 gen.mod_info, 1259 info, 1260 configs, 1261 gen.resource_manager.get_src_file_path(), 1262 enabled_features, 1263 ), 1264 data_dep_refs=find_data_dep_refs( 1265 gen.mod_info, 1266 info, 1267 configs, 1268 gen.resource_manager.get_src_file_path(), 1269 ), 1270 device_data_dep_refs=find_device_data_dep_refs(gen, info), 1271 ), 1272 [ 1273 c 1274 for c in configs 1275 if c.name 1276 in map(str.lower, info.get(constants.MODULE_SUPPORTED_VARIANTS, [])) 1277 ], 1278 ) 1279 1280 def __init__( 1281 self, 1282 info: Dict[str, Any], 1283 package_name: str, 1284 config_files: Dict[Config, List[pathlib.Path]], 1285 deps: Dependencies, 1286 supported_configs: List[Config], 1287 ): 1288 self._target_name = info[constants.MODULE_INFO_ID] 1289 self._module_name = info[constants.MODULE_NAME] 1290 self._package_name = package_name 1291 self.config_files = config_files 1292 self.deps = deps 1293 self.suites = info.get(constants.MODULE_COMPATIBILITY_SUITES, []) 1294 self._supported_configs = supported_configs 1295 1296 def name(self) -> str: 1297 return self._target_name 1298 1299 def package_name(self) -> str: 1300 return self._package_name 1301 1302 def required_imports(self) -> Set[Import]: 1303 return { 1304 Import('//bazel/rules:soong_prebuilt.bzl', self._rule_name()), 1305 } 1306 1307 @functools.lru_cache(maxsize=128) 1308 def supported_configs(self) -> Set[Config]: 1309 # We deduce the supported configs from the installed paths since the 1310 # build exports incorrect metadata for some module types such as 1311 # Robolectric. The information exported from the build is only used if 1312 # the module does not have any installed paths. 1313 # TODO(b/232929584): Remove this once all modules correctly export the 1314 # supported variants. 1315 supported_configs = set(self.config_files.keys()) 1316 if supported_configs: 1317 return supported_configs 1318 1319 return self._supported_configs 1320 1321 def dependencies(self) -> List[ModuleRef]: 1322 all_deps = set(self.deps.runtime_dep_refs) 1323 all_deps.update(self.deps.data_dep_refs) 1324 all_deps.update(self.deps.device_data_dep_refs) 1325 all_deps.update(self.deps.static_dep_refs) 1326 return list(all_deps) 1327 1328 def write_to_build_file(self, f: IO): 1329 writer = IndentWriter(f) 1330 build_file_writer = BuildFileWriter(writer) 1331 1332 writer.write_line(f'{self._rule_name()}(') 1333 1334 with writer.indent(): 1335 writer.write_line(f'name = "{self._target_name}",') 1336 writer.write_line(f'module_name = "{self._module_name}",') 1337 self._write_files_attribute(writer) 1338 self._write_deps_attribute( 1339 writer, 'static_deps', self.deps.static_dep_refs 1340 ) 1341 self._write_deps_attribute( 1342 writer, 'runtime_deps', self.deps.runtime_dep_refs 1343 ) 1344 self._write_deps_attribute(writer, 'data', self.deps.data_dep_refs) 1345 1346 build_file_writer.write_label_list_attribute( 1347 'device_data', self.deps.device_data_dep_refs 1348 ) 1349 build_file_writer.write_string_list_attribute( 1350 'suites', sorted(self.suites) 1351 ) 1352 1353 writer.write_line(')') 1354 1355 def create_filesystem_layout(self, package_dir: pathlib.Path): 1356 prebuilts_dir = package_dir.joinpath(self._target_name) 1357 prebuilts_dir.mkdir() 1358 1359 for config, files in self.config_files.items(): 1360 config_prebuilts_dir = prebuilts_dir.joinpath(config.name) 1361 config_prebuilts_dir.mkdir() 1362 1363 for f in files: 1364 rel_path = f.relative_to(config.out_path) 1365 symlink = config_prebuilts_dir.joinpath(rel_path) 1366 symlink.parent.mkdir(parents=True, exist_ok=True) 1367 symlink.symlink_to(f) 1368 1369 def _rule_name(self): 1370 return ( 1371 'soong_prebuilt' if self.config_files else 'soong_uninstalled_prebuilt' 1372 ) 1373 1374 def _write_files_attribute(self, writer: IndentWriter): 1375 if not self.config_files: 1376 return 1377 1378 writer.write('files = ') 1379 write_config_select( 1380 writer, 1381 self.config_files, 1382 lambda c, _: writer.write( 1383 f'glob(["{self._target_name}/{c.name}/**/*"])' 1384 ), 1385 ) 1386 writer.write_line(',') 1387 1388 def _write_deps_attribute(self, writer, attribute_name, module_refs): 1389 config_deps = filter_configs( 1390 group_targets_by_config(r.target() for r in module_refs), 1391 self.supported_configs(), 1392 ) 1393 1394 if not config_deps: 1395 return 1396 1397 for config in self.supported_configs(): 1398 config_deps.setdefault(config, []) 1399 1400 writer.write(f'{attribute_name} = ') 1401 write_config_select( 1402 writer, 1403 config_deps, 1404 lambda _, targets: write_target_list(writer, targets), 1405 ) 1406 writer.write_line(',') 1407 1408 1409def group_paths_by_config( 1410 configs: List[Config], paths: List[pathlib.Path] 1411) -> Dict[Config, List[pathlib.Path]]: 1412 1413 config_files = defaultdict(list) 1414 1415 for f in paths: 1416 matching_configs = [c for c in configs if _is_relative_to(f, c.out_path)] 1417 1418 if not matching_configs: 1419 continue 1420 1421 # The path can only appear in ANDROID_HOST_OUT for host target or 1422 # ANDROID_PRODUCT_OUT, but cannot appear in both. 1423 if len(matching_configs) > 1: 1424 raise ValueError( 1425 f'Installed path `{f}` is not in' 1426 ' ANDROID_HOST_OUT or ANDROID_PRODUCT_OUT' 1427 ) 1428 1429 config_files[matching_configs[0]].append(f) 1430 1431 return config_files 1432 1433 1434def group_targets_by_config( 1435 targets: List[Target], 1436) -> Dict[Config, List[Target]]: 1437 1438 config_to_targets = defaultdict(list) 1439 1440 for target in targets: 1441 for config in target.supported_configs(): 1442 config_to_targets[config].append(target) 1443 1444 return config_to_targets 1445 1446 1447def filter_configs( 1448 config_dict: Dict[Config, Any], 1449 configs: Set[Config], 1450) -> Dict[Config, Any]: 1451 return {k: v for (k, v) in config_dict.items() if k in configs} 1452 1453 1454def _is_relative_to(path1: pathlib.Path, path2: pathlib.Path) -> bool: 1455 """Return True if the path is relative to another path or False.""" 1456 # Note that this implementation is required because Path.is_relative_to only 1457 # exists starting with Python 3.9. 1458 try: 1459 path1.relative_to(path2) 1460 return True 1461 except ValueError: 1462 return False 1463 1464 1465def get_module_installed_paths( 1466 info: Dict[str, Any], src_root_path: pathlib.Path 1467) -> List[pathlib.Path]: 1468 1469 # Install paths in module-info are usually relative to the Android 1470 # source root ${ANDROID_BUILD_TOP}. When the output directory is 1471 # customized by the user however, the install paths are absolute. 1472 def resolve(install_path_string): 1473 install_path = pathlib.Path(install_path_string) 1474 if not install_path.expanduser().is_absolute(): 1475 return src_root_path.joinpath(install_path) 1476 return install_path 1477 1478 return map(resolve, info.get(constants.MODULE_INSTALLED, [])) 1479 1480 1481def find_runtime_dep_refs( 1482 mod_info: module_info.ModuleInfo, 1483 info: module_info.Module, 1484 configs: List[Config], 1485 src_root_path: pathlib.Path, 1486 enabled_features: List[Features], 1487) -> List[ModuleRef]: 1488 """Return module references for runtime dependencies.""" 1489 1490 # We don't use the `dependencies` module-info field for shared libraries 1491 # since it's ambiguous and could generate more targets and pull in more 1492 # dependencies than necessary. In particular, libraries that support both 1493 # static and dynamic linking could end up becoming runtime dependencies 1494 # even though the build specifies static linking. For example, if a target 1495 # 'T' is statically linked to 'U' which supports both variants, the latter 1496 # still appears as a dependency. Since we can't tell, this would result in 1497 # the shared library variant of 'U' being added on the library path. 1498 libs = set() 1499 libs.update(info.get(constants.MODULE_SHARED_LIBS, [])) 1500 libs.update(info.get(constants.MODULE_RUNTIME_DEPS, [])) 1501 1502 if Features.EXPERIMENTAL_JAVA_RUNTIME_DEPENDENCIES in enabled_features: 1503 libs.update(info.get(constants.MODULE_LIBS, [])) 1504 1505 runtime_dep_refs = _find_module_refs(mod_info, configs, src_root_path, libs) 1506 1507 runtime_library_class = {'RLIB_LIBRARIES', 'DYLIB_LIBRARIES'} 1508 # We collect rlibs even though they are technically static libraries since 1509 # they could refer to dylibs which are required at runtime. Generating 1510 # Bazel targets for these intermediate modules keeps the generator simple 1511 # and preserves the shape (isomorphic) of the Soong structure making the 1512 # workspace easier to debug. 1513 for dep_name in info.get(constants.MODULE_DEPENDENCIES, []): 1514 dep_info = mod_info.get_module_info(dep_name) 1515 if not dep_info: 1516 continue 1517 if not runtime_library_class.intersection( 1518 dep_info.get(constants.MODULE_CLASS, []) 1519 ): 1520 continue 1521 runtime_dep_refs.append(ModuleRef.for_info(dep_info)) 1522 1523 return runtime_dep_refs 1524 1525 1526def find_data_dep_refs( 1527 mod_info: module_info.ModuleInfo, 1528 info: module_info.Module, 1529 configs: List[Config], 1530 src_root_path: pathlib.Path, 1531) -> List[ModuleRef]: 1532 """Return module references for data dependencies.""" 1533 1534 return _find_module_refs( 1535 mod_info, configs, src_root_path, info.get(constants.MODULE_DATA_DEPS, []) 1536 ) 1537 1538 1539def find_device_data_dep_refs( 1540 gen: WorkspaceGenerator, 1541 info: module_info.Module, 1542) -> List[ModuleRef]: 1543 """Return module references for device data dependencies.""" 1544 1545 return _find_module_refs( 1546 gen.mod_info, 1547 [Config('device', gen.resource_manager.get_product_out_file_path())], 1548 gen.resource_manager.get_src_file_path(), 1549 info.get(constants.MODULE_TARGET_DEPS, []), 1550 ) 1551 1552 1553def find_static_dep_refs( 1554 mod_info: module_info.ModuleInfo, 1555 info: module_info.Module, 1556 configs: List[Config], 1557 src_root_path: pathlib.Path, 1558 enabled_features: List[Features], 1559) -> List[ModuleRef]: 1560 """Return module references for static libraries.""" 1561 1562 if Features.EXPERIMENTAL_JAVA_RUNTIME_DEPENDENCIES not in enabled_features: 1563 return [] 1564 1565 static_libs = set() 1566 static_libs.update(info.get(constants.MODULE_STATIC_LIBS, [])) 1567 static_libs.update(info.get(constants.MODULE_STATIC_DEPS, [])) 1568 1569 return _find_module_refs(mod_info, configs, src_root_path, static_libs) 1570 1571 1572def _find_module_refs( 1573 mod_info: module_info.ModuleInfo, 1574 configs: List[Config], 1575 src_root_path: pathlib.Path, 1576 module_names: List[str], 1577) -> List[ModuleRef]: 1578 """Return module references for modules.""" 1579 1580 module_refs = [] 1581 1582 for name in module_names: 1583 info = mod_info.get_module_info(name) 1584 if not info: 1585 continue 1586 1587 installed_paths = get_module_installed_paths(info, src_root_path) 1588 config_files = group_paths_by_config(configs, installed_paths) 1589 if not config_files: 1590 continue 1591 1592 module_refs.append(ModuleRef.for_info(info)) 1593 1594 return module_refs 1595 1596 1597class IndentWriter: 1598 1599 def __init__(self, f: IO): 1600 self._file = f 1601 self._indent_level = 0 1602 self._indent_string = 4 * ' ' 1603 self._indent_next = True 1604 1605 def write_line(self, text: str = ''): 1606 if text: 1607 self.write(text) 1608 1609 self._file.write('\n') 1610 self._indent_next = True 1611 1612 def write(self, text): 1613 if self._indent_next: 1614 self._file.write(self._indent_string * self._indent_level) 1615 self._indent_next = False 1616 1617 self._file.write(text) 1618 1619 @contextlib.contextmanager 1620 def indent(self): 1621 self._indent_level += 1 1622 yield 1623 self._indent_level -= 1 1624 1625 1626def write_config_select( 1627 writer: IndentWriter, 1628 config_dict: Dict[Config, Any], 1629 write_value_fn: Callable, 1630): 1631 writer.write_line('select({') 1632 1633 with writer.indent(): 1634 for config, value in sorted(config_dict.items(), key=lambda c: c[0].name): 1635 1636 writer.write(f'"//bazel/rules:{config.name}": ') 1637 write_value_fn(config, value) 1638 writer.write_line(',') 1639 1640 writer.write('})') 1641 1642 1643def write_target_list(writer: IndentWriter, targets: List[Target]): 1644 writer.write_line('[') 1645 1646 with writer.indent(): 1647 for label in sorted(set(t.qualified_name() for t in targets)): 1648 writer.write_line(f'"{label}",') 1649 1650 writer.write(']') 1651 1652 1653def _decorate_find_method(mod_info, finder_method_func, host, enabled_features): 1654 """A finder_method decorator to override TestInfo properties.""" 1655 1656 def use_bazel_runner(finder_obj, test_id): 1657 test_infos = finder_method_func(finder_obj, test_id) 1658 if not test_infos: 1659 return test_infos 1660 for tinfo in test_infos: 1661 m_info = mod_info.get_module_info(tinfo.test_name) 1662 1663 # TODO(b/262200630): Refactor the duplicated logic in 1664 # _decorate_find_method() and _add_test_module_targets() to 1665 # determine whether a test should run with Atest Bazel Mode. 1666 1667 # Only enable modern Robolectric tests since those are the only ones 1668 # TF currently supports. 1669 if mod_info.is_modern_robolectric_test(m_info): 1670 if Features.EXPERIMENTAL_ROBOLECTRIC_TEST in enabled_features: 1671 tinfo.test_runner = BazelTestRunner.NAME 1672 continue 1673 1674 # Only run device-driven tests in Bazel mode when '--host' is not 1675 # specified and the feature is enabled. 1676 if not host and mod_info.is_device_driven_test(m_info): 1677 if Features.EXPERIMENTAL_DEVICE_DRIVEN_TEST in enabled_features: 1678 tinfo.test_runner = BazelTestRunner.NAME 1679 continue 1680 1681 if mod_info.is_suite_in_compatibility_suites( 1682 'host-unit-tests', m_info 1683 ) or ( 1684 Features.EXPERIMENTAL_HOST_DRIVEN_TEST in enabled_features 1685 and mod_info.is_host_driven_test(m_info) 1686 ): 1687 tinfo.test_runner = BazelTestRunner.NAME 1688 return test_infos 1689 1690 return use_bazel_runner 1691 1692 1693def create_new_finder( 1694 mod_info: module_info.ModuleInfo, 1695 finder: test_finder_base.TestFinderBase, 1696 host: bool, 1697 enabled_features: List[Features] = None, 1698): 1699 """Create new test_finder_base.Finder with decorated find_method. 1700 1701 Args: 1702 mod_info: ModuleInfo object. 1703 finder: Test Finder class. 1704 host: Whether to run the host variant. 1705 enabled_features: List of enabled features. 1706 1707 Returns: 1708 List of ordered find methods. 1709 """ 1710 return test_finder_base.Finder( 1711 finder.test_finder_instance, 1712 _decorate_find_method( 1713 mod_info, finder.find_method, host, enabled_features or [] 1714 ), 1715 finder.finder_info, 1716 ) 1717 1718 1719class RunCommandError(subprocess.CalledProcessError): 1720 """CalledProcessError but including debug information when it fails.""" 1721 1722 def __str__(self): 1723 return f'{super().__str__()}\nstdout={self.stdout}\n\nstderr={self.stderr}' 1724 1725 1726def default_run_command(args: List[str], cwd: pathlib.Path) -> str: 1727 result = subprocess.run( 1728 args=args, 1729 cwd=cwd, 1730 text=True, 1731 capture_output=True, 1732 check=False, 1733 ) 1734 if result.returncode: 1735 # Provide a more detailed log message including stdout and stderr. 1736 raise RunCommandError( 1737 result.returncode, result.args, result.stdout, result.stderr 1738 ) 1739 return result.stdout 1740 1741 1742@dataclasses.dataclass 1743class BuildMetadata: 1744 build_branch: str 1745 build_target: str 1746 1747 1748class BazelTestRunner(trb.TestRunnerBase): 1749 """Bazel Test Runner class.""" 1750 1751 NAME = 'BazelTestRunner' 1752 EXECUTABLE = 'none' 1753 1754 # pylint: disable=redefined-outer-name 1755 # pylint: disable=too-many-arguments 1756 def __init__( 1757 self, 1758 results_dir, 1759 mod_info: module_info.ModuleInfo, 1760 extra_args: Dict[str, Any] = None, 1761 src_top: pathlib.Path = None, 1762 workspace_path: pathlib.Path = None, 1763 run_command: Callable = default_run_command, 1764 build_metadata: BuildMetadata = None, 1765 env: Dict[str, str] = None, 1766 generate_workspace_fn: Callable = generate_bazel_workspace, 1767 enabled_features: Set[str] = None, 1768 **kwargs, 1769 ): 1770 super().__init__(results_dir, **kwargs) 1771 self.mod_info = mod_info 1772 self.src_top = src_top or pathlib.Path( 1773 os.environ.get(constants.ANDROID_BUILD_TOP) 1774 ) 1775 self.starlark_file = _get_resource_root().joinpath( 1776 'format_as_soong_module_name.cquery' 1777 ) 1778 1779 self.bazel_workspace = workspace_path or get_bazel_workspace_dir() 1780 self.bazel_binary = self.bazel_workspace.joinpath('bazel.sh') 1781 self.run_command = run_command 1782 self._extra_args = extra_args or {} 1783 self.build_metadata = build_metadata or get_default_build_metadata() 1784 self.env = env or os.environ 1785 self._generate_workspace_fn = generate_workspace_fn 1786 self._enabled_features = ( 1787 enabled_features 1788 if enabled_features is not None 1789 else atest_configs.GLOBAL_ARGS.bazel_mode_features 1790 ) 1791 1792 # pylint: disable=unused-argument 1793 def run_tests(self, test_infos, extra_args, reporter): 1794 """Run the list of test_infos. 1795 1796 Args: 1797 test_infos: List of TestInfo. 1798 extra_args: Dict of extra args to add to test run. 1799 reporter: An instance of result_report.ResultReporter. 1800 """ 1801 ret_code = ExitCode.SUCCESS 1802 1803 try: 1804 run_cmds = self.generate_run_commands(test_infos, extra_args) 1805 except AbortRunException as e: 1806 atest_utils.colorful_print(f'Stop running test(s): {e}', constants.RED) 1807 return ExitCode.ERROR 1808 1809 for run_cmd in run_cmds: 1810 subproc = self.run(run_cmd, output_to_stdout=True) 1811 ret_code |= self.wait_for_subprocess(subproc) 1812 1813 self.organize_test_logs(test_infos) 1814 1815 return ret_code 1816 1817 def organize_test_logs(self, test_infos: List[test_info.TestInfo]): 1818 for t_info in test_infos: 1819 test_output_dir, package_name, target_suffix = ( 1820 self.retrieve_test_output_info(t_info) 1821 ) 1822 if test_output_dir.joinpath(TEST_OUTPUT_ZIP_NAME).exists(): 1823 # TEST_OUTPUT_ZIP file exist when BES uploading is enabled. 1824 # Showing the BES link to users instead of the local log. 1825 continue 1826 1827 # AtestExecutionInfo will find all log files in 'results_dir/log' 1828 # directory and generate an HTML file to display to users when 1829 # 'results_dir/log' directory exist. 1830 log_path = pathlib.Path(self.results_dir).joinpath( 1831 'log', f'{package_name}', f'{t_info.test_name}_{target_suffix}' 1832 ) 1833 log_path.parent.mkdir(parents=True, exist_ok=True) 1834 if not log_path.is_symlink(): 1835 log_path.symlink_to(test_output_dir) 1836 1837 def _get_feature_config_or_warn(self, feature, env_var_name): 1838 feature_config = self.env.get(env_var_name) 1839 if not feature_config: 1840 atest_utils.print_and_log_warning( 1841 'Ignoring `%s` because the `%s` environment variable is not set.', 1842 # pylint: disable=no-member 1843 feature, 1844 env_var_name, 1845 ) 1846 return feature_config 1847 1848 def _get_bes_publish_args(self, feature: Features) -> List[str]: 1849 bes_publish_config = self._get_feature_config_or_warn( 1850 feature, 'ATEST_BAZEL_BES_PUBLISH_CONFIG' 1851 ) 1852 1853 if not bes_publish_config: 1854 return [] 1855 1856 branch = self.build_metadata.build_branch 1857 target = self.build_metadata.build_target 1858 1859 return [ 1860 f'--config={bes_publish_config}', 1861 f'--build_metadata=ab_branch={branch}', 1862 f'--build_metadata=ab_target={target}', 1863 ] 1864 1865 def _get_remote_args(self, feature): 1866 remote_config = self._get_feature_config_or_warn( 1867 feature, 'ATEST_BAZEL_REMOTE_CONFIG' 1868 ) 1869 if not remote_config: 1870 return [] 1871 return [f'--config={remote_config}'] 1872 1873 def _get_remote_avd_args(self, feature): 1874 remote_avd_config = self._get_feature_config_or_warn( 1875 feature, 'ATEST_BAZEL_REMOTE_AVD_CONFIG' 1876 ) 1877 if not remote_avd_config: 1878 raise ValueError( 1879 'Cannot run remote device test because ' 1880 'ATEST_BAZEL_REMOTE_AVD_CONFIG ' 1881 'environment variable is not set.' 1882 ) 1883 return [f'--config={remote_avd_config}'] 1884 1885 def host_env_check(self): 1886 """Check that host env has everything we need. 1887 1888 We actually can assume the host env is fine because we have the same 1889 requirements that atest has. Update this to check for android env vars 1890 if that changes. 1891 """ 1892 1893 def get_test_runner_build_reqs(self, test_infos) -> Set[str]: 1894 if not test_infos: 1895 return set() 1896 1897 self._generate_workspace_fn( 1898 self.mod_info, 1899 self._enabled_features, 1900 ) 1901 1902 deps_expression = ' + '.join( 1903 sorted(self.test_info_target_label(i) for i in test_infos) 1904 ) 1905 1906 with tempfile.NamedTemporaryFile() as query_file: 1907 with open(query_file.name, 'w', encoding='utf-8') as _query_file: 1908 _query_file.write(f'deps(tests({deps_expression}))') 1909 1910 query_args = [ 1911 str(self.bazel_binary), 1912 'cquery', 1913 f'--query_file={query_file.name}', 1914 '--output=starlark', 1915 f'--starlark:file={self.starlark_file}', 1916 ] 1917 1918 output = self.run_command(query_args, self.bazel_workspace) 1919 1920 targets = set() 1921 robolectric_tests = set( 1922 filter( 1923 self._is_robolectric_test_suite, 1924 [test.test_name for test in test_infos], 1925 ) 1926 ) 1927 1928 modules_to_variant = _parse_cquery_output(output) 1929 1930 for module, variants in modules_to_variant.items(): 1931 1932 # Skip specifying the build variant for Robolectric test modules 1933 # since they are special. Soong builds them with the `target` 1934 # variant although are installed as 'host' modules. 1935 if module in robolectric_tests: 1936 targets.add(module) 1937 continue 1938 1939 targets.add(_soong_target_for_variants(module, variants)) 1940 1941 return targets 1942 1943 def _is_robolectric_test_suite(self, module_name: str) -> bool: 1944 return self.mod_info.is_robolectric_test_suite( 1945 self.mod_info.get_module_info(module_name) 1946 ) 1947 1948 def test_info_target_label(self, test: test_info.TestInfo) -> str: 1949 module_name = test.test_name 1950 info = self.mod_info.get_module_info(module_name) 1951 package_name = info.get(constants.MODULE_PATH)[0] 1952 target_suffix = self.get_target_suffix(info) 1953 1954 return f'//{package_name}:{module_name}_{target_suffix}' 1955 1956 def retrieve_test_output_info( 1957 self, test_info: test_info.TestInfo 1958 ) -> Tuple[pathlib.Path, str, str]: 1959 """Return test output information. 1960 1961 Args: 1962 test_info (test_info.TestInfo): Information about the test. 1963 1964 Returns: 1965 Tuple[pathlib.Path, str, str]: A tuple containing the following 1966 elements: 1967 - test_output_dir (pathlib.Path): Absolute path of the test output 1968 folder. 1969 - package_name (str): Name of the package. 1970 - target_suffix (str): Target suffix. 1971 """ 1972 module_name = test_info.test_name 1973 info = self.mod_info.get_module_info(module_name) 1974 package_name = info.get(constants.MODULE_PATH)[0] 1975 target_suffix = self.get_target_suffix(info) 1976 1977 test_output_dir = pathlib.Path( 1978 self.bazel_workspace, 1979 BAZEL_TEST_LOGS_DIR_NAME, 1980 package_name, 1981 f'{module_name}_{target_suffix}', 1982 TEST_OUTPUT_DIR_NAME, 1983 ) 1984 1985 return test_output_dir, package_name, target_suffix 1986 1987 def get_target_suffix(self, info: Dict[str, Any]) -> str: 1988 """Return 'host' or 'device' accordingly to the variant of the test.""" 1989 if not self._extra_args.get( 1990 constants.HOST, False 1991 ) and self.mod_info.is_device_driven_test(info): 1992 return 'device' 1993 return 'host' 1994 1995 @staticmethod 1996 def _get_bazel_feature_args( 1997 feature: Features, extra_args: Dict[str, Any], generator: Callable 1998 ) -> List[str]: 1999 if feature not in extra_args.get('BAZEL_MODE_FEATURES', []): 2000 return [] 2001 return generator(feature) 2002 2003 # pylint: disable=unused-argument 2004 def generate_run_commands(self, test_infos, extra_args, port=None): 2005 """Generate a list of run commands from TestInfos. 2006 2007 Args: 2008 test_infos: A set of TestInfo instances. 2009 extra_args: A Dict of extra args to append. 2010 port: Optional. An int of the port number to send events to. 2011 2012 Returns: 2013 A list of run commands to run the tests. 2014 """ 2015 startup_options = '' 2016 bazelrc = self.env.get('ATEST_BAZELRC') 2017 2018 if bazelrc: 2019 startup_options = f'--bazelrc={bazelrc}' 2020 2021 target_patterns = ' '.join( 2022 self.test_info_target_label(i) for i in test_infos 2023 ) 2024 2025 bazel_args = parse_args(test_infos, extra_args) 2026 2027 # If BES is not enabled, use the option of 2028 # '--nozip_undeclared_test_outputs' to not compress the test outputs. 2029 # And the URL of test outputs will be printed in terminal. 2030 bazel_args.extend( 2031 self._get_bazel_feature_args( 2032 Features.EXPERIMENTAL_BES_PUBLISH, 2033 extra_args, 2034 self._get_bes_publish_args, 2035 ) 2036 or ['--nozip_undeclared_test_outputs'] 2037 ) 2038 bazel_args.extend( 2039 self._get_bazel_feature_args( 2040 Features.EXPERIMENTAL_REMOTE, extra_args, self._get_remote_args 2041 ) 2042 ) 2043 bazel_args.extend( 2044 self._get_bazel_feature_args( 2045 Features.EXPERIMENTAL_REMOTE_AVD, 2046 extra_args, 2047 self._get_remote_avd_args, 2048 ) 2049 ) 2050 2051 # This is an alternative to shlex.join that doesn't exist in Python 2052 # versions < 3.8. 2053 bazel_args_str = ' '.join(shlex.quote(arg) for arg in bazel_args) 2054 2055 # Use 'cd' instead of setting the working directory in the subprocess 2056 # call for a working --dry-run command that users can run. 2057 return [ 2058 f'cd {self.bazel_workspace} && ' 2059 f'{self.bazel_binary} {startup_options} ' 2060 f'test {target_patterns} {bazel_args_str}' 2061 ] 2062 2063 2064def parse_args( 2065 test_infos: List[test_info.TestInfo], extra_args: Dict[str, Any] 2066) -> Dict[str, Any]: 2067 """Parse commandline args and passes supported args to bazel. 2068 2069 Args: 2070 test_infos: A set of TestInfo instances. 2071 extra_args: A Dict of extra args to append. 2072 2073 Returns: 2074 A list of args to append to the run command. 2075 """ 2076 2077 args_to_append = [] 2078 # Make a copy of the `extra_args` dict to avoid modifying it for other 2079 # Atest runners. 2080 extra_args_copy = extra_args.copy() 2081 2082 # Remove the `--host` flag since we already pass that in the rule's 2083 # implementation. 2084 extra_args_copy.pop(constants.HOST, None) 2085 2086 # Remove the serial arg since Bazel mode does not support device tests and 2087 # the serial / -s arg conflicts with the TF null device option specified in 2088 # the rule implementation (-n). 2089 extra_args_copy.pop(constants.SERIAL, None) 2090 2091 # Map args to their native Bazel counterparts. 2092 for arg in _SUPPORTED_BAZEL_ARGS: 2093 if arg not in extra_args_copy: 2094 continue 2095 args_to_append.extend(_map_to_bazel_args(arg, extra_args_copy[arg])) 2096 # Remove the argument since we already mapped it to a Bazel option 2097 # and no longer need it mapped to a Tradefed argument below. 2098 del extra_args_copy[arg] 2099 2100 # TODO(b/215461642): Store the extra_args in the top-level object so 2101 # that we don't have to re-parse the extra args to get BAZEL_ARG again. 2102 tf_args, _ = tfr.extra_args_to_tf_args(extra_args_copy) 2103 2104 # Add ATest include filter argument to allow testcase filtering. 2105 tf_args.extend(tfr.get_include_filter(test_infos)) 2106 2107 args_to_append.extend([f'--test_arg={i}' for i in tf_args]) 2108 2109 # Disable test result caching when wait-for-debugger flag is set. 2110 if '--wait-for-debugger' in tf_args: 2111 # Remove the --cache_test_results flag if it's already set. 2112 args_to_append = [ 2113 arg 2114 for arg in args_to_append 2115 if not arg.startswith('--cache_test_results') 2116 ] 2117 args_to_append.append('--cache_test_results=no') 2118 2119 # Default to --test_output=errors unless specified otherwise 2120 if not any(arg.startswith('--test_output=') for arg in args_to_append): 2121 args_to_append.append('--test_output=errors') 2122 2123 # Default to --test_summary=detailed unless specified otherwise, or if the 2124 # feature is disabled 2125 if not any(arg.startswith('--test_summary=') for arg in args_to_append) and ( 2126 Features.NO_BAZEL_DETAILED_SUMMARY 2127 not in extra_args.get('BAZEL_MODE_FEATURES', []) 2128 ): 2129 args_to_append.append('--test_summary=detailed') 2130 2131 return args_to_append 2132 2133 2134def _map_to_bazel_args(arg: str, arg_value: Any) -> List[str]: 2135 return ( 2136 _SUPPORTED_BAZEL_ARGS[arg](arg_value) 2137 if arg in _SUPPORTED_BAZEL_ARGS 2138 else [] 2139 ) 2140 2141 2142def _parse_cquery_output(output: str) -> Dict[str, Set[str]]: 2143 module_to_build_variants = defaultdict(set) 2144 2145 for line in filter(bool, map(str.strip, output.splitlines())): 2146 module_name, build_variant = line.split(':') 2147 module_to_build_variants[module_name].add(build_variant) 2148 2149 return module_to_build_variants 2150 2151 2152def _soong_target_for_variants( 2153 module_name: str, build_variants: Set[str] 2154) -> str: 2155 2156 if not build_variants: 2157 raise ValueError( 2158 f'Missing the build variants for module {module_name} in cquery output!' 2159 ) 2160 2161 if len(build_variants) > 1: 2162 return module_name 2163 2164 return f'{module_name}-{_CONFIG_TO_VARIANT[list(build_variants)[0]]}' 2165