# Copyright 2023 The Chromium Authors # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. """Methods for managing deps based on build_config.json files.""" from __future__ import annotations import collections import dataclasses import json import logging import os import pathlib import subprocess import sys from typing import Dict, Iterator, List, Set from util import jar_utils _SRC_PATH = pathlib.Path(__file__).resolve().parents[4] sys.path.append(str(_SRC_PATH / 'build/android')) # Import list_java_targets so that the dependency is found by print_python_deps. import list_java_targets @dataclasses.dataclass(frozen=True) class ClassEntry: """An assignment of a Java class to a build target.""" full_class_name: str target: str preferred_dep: bool def __lt__(self, other: 'ClassEntry'): # Prefer canonical targets first. if self.preferred_dep and not other.preferred_dep: return True # Prefer targets without __ in the name. Usually double underscores are used # for internal subtargets and not top level targets. if '__' not in self.target and '__' in other.target: return True # Prefer shorter target names first since they are usually the correct ones. if len(self.target) < len(other.target): return True if len(self.target) > len(other.target): return False # Use string comparison to get a stable ordering of equal-length names. return self.target < other.target @dataclasses.dataclass class BuildConfig: """Container for information from a build config.""" target_name: str relpath: str is_group: bool preferred_dep: bool dependent_config_paths: List[str] full_class_names: Set[str] def all_dependent_configs( self, path_to_configs: Dict[str, 'BuildConfig'], ) -> Iterator['BuildConfig']: for path in self.dependent_config_paths: dep_build_config = path_to_configs.get(path) # This can happen when a java group depends on non-java targets. if dep_build_config is None: continue yield dep_build_config if dep_build_config.is_group: yield from dep_build_config.all_dependent_configs(path_to_configs) class ClassLookupIndex: """A map from full Java class to its build targets. A class might be in multiple targets if it's bytecode rewritten.""" def __init__(self, build_output_dir: pathlib.Path, should_build: bool): self._abs_build_output_dir = build_output_dir.resolve().absolute() self._should_build = should_build self._class_index = self._index_root() def match(self, search_string: str) -> List[ClassEntry]: """Get class/target entries where the class matches search_string""" # Priority 1: Exact full matches if search_string in self._class_index: return self._entries_for(search_string) # Priority 2: Match full class name (any case), if it's a class name matches = [] lower_search_string = search_string.lower() if '.' not in lower_search_string: for full_class_name in self._class_index: package_and_class = full_class_name.rsplit('.', 1) if len(package_and_class) < 2: continue class_name = package_and_class[1] class_lower = class_name.lower() if class_lower == lower_search_string: matches.extend(self._entries_for(full_class_name)) if matches: return matches # Priority 3: Match anything for full_class_name in self._class_index: if lower_search_string in full_class_name.lower(): matches.extend(self._entries_for(full_class_name)) # Priority 4: Match parent class when no matches and it's an inner class. if not matches: components = search_string.rsplit('.', 2) if len(components) == 3: package, outer_class, inner_class = components if outer_class[0].isupper() and inner_class[0].isupper(): matches.extend(self.match(f'{package}.{outer_class}')) return matches def _entries_for(self, class_name) -> List[ClassEntry]: return sorted(self._class_index[class_name]) def _index_root(self) -> Dict[str, Set[ClassEntry]]: """Create the class to target index.""" logging.debug('Running list_java_targets.py...') list_java_targets_command = [ 'build/android/list_java_targets.py', '--gn-labels', '--print-build-config-paths', f'--output-directory={self._abs_build_output_dir}' ] if self._should_build: list_java_targets_command += ['--build'] list_java_targets_run = subprocess.run(list_java_targets_command, cwd=_SRC_PATH, capture_output=True, text=True, check=True) logging.debug('... done.') # Parse output of list_java_targets.py into BuildConfig objects. path_to_build_config: Dict[str, BuildConfig] = {} target_lines = list_java_targets_run.stdout.splitlines() for target_line in target_lines: # Skip empty lines if not target_line: continue target_line_parts = target_line.split(': ') assert len(target_line_parts) == 2, target_line_parts target_name, build_config_path = target_line_parts if not os.path.exists(build_config_path): assert not self._should_build continue with open(build_config_path) as build_config_contents: build_config_json: Dict = json.load(build_config_contents) deps_info = build_config_json['deps_info'] # Checking the library type here instead of in list_java_targets.py avoids # reading each .build_config file twice. if deps_info['type'] not in ('java_library', 'group'): continue relpath = os.path.relpath(build_config_path, self._abs_build_output_dir) preferred_dep = bool(deps_info.get('preferred_dep')) is_group = bool(deps_info.get('type') == 'group') dependent_config_paths = deps_info.get('deps_configs', []) full_class_names = self._compute_full_class_names_for_build_config( deps_info) build_config = BuildConfig(relpath=relpath, target_name=target_name, is_group=is_group, preferred_dep=preferred_dep, dependent_config_paths=dependent_config_paths, full_class_names=full_class_names) path_to_build_config[relpath] = build_config # From GN's perspective, depending on a java group is the same as depending # on all of its deps directly, since groups are collapsed in # write_build_config.py. Thus, collect all the java files in a java group's # deps (recursing into other java groups) and set that as the java group's # list of classes. for build_config in path_to_build_config.values(): if build_config.is_group: for dep_build_config in build_config.all_dependent_configs( path_to_build_config): build_config.full_class_names.update( dep_build_config.full_class_names) class_index = collections.defaultdict(set) for build_config in path_to_build_config.values(): for full_class_name in build_config.full_class_names: class_index[full_class_name].add( ClassEntry(full_class_name=full_class_name, target=build_config.target_name, preferred_dep=build_config.preferred_dep)) return class_index def _compute_full_class_names_for_build_config(self, deps_info: Dict) -> Set[str]: """Returns set of fully qualified class names for build config.""" full_class_names = set() # Read the location of the target_sources_file from the build_config sources_path = deps_info.get('target_sources_file') if sources_path: # Read the target_sources_file, indexing the classes found with open(self._abs_build_output_dir / sources_path) as sources_contents: for source_line in sources_contents: source_path = pathlib.Path(source_line.strip()) java_class = jar_utils.parse_full_java_class(source_path) if java_class: full_class_names.add(java_class) # |unprocessed_jar_path| is set for prebuilt targets. (ex: # android_aar_prebuilt()) # |unprocessed_jar_path| might be set but not exist if not all targets have # been built. unprocessed_jar_path = deps_info.get('unprocessed_jar_path') if unprocessed_jar_path: abs_unprocessed_jar_path = (self._abs_build_output_dir / unprocessed_jar_path) if abs_unprocessed_jar_path.exists(): # Normalize path but do not follow symlink if .jar is symlink. abs_unprocessed_jar_path = (abs_unprocessed_jar_path.parent.resolve() / abs_unprocessed_jar_path.name) full_class_names.update( jar_utils.extract_full_class_names_from_jar( abs_unprocessed_jar_path)) return full_class_names def GnTargetToBuildFilePath(gn_target: str): """Returns the relative BUILD.gn file path for this target from src root.""" assert gn_target.startswith('//'), f'Relative {gn_target} name not supported.' ninja_target_name = gn_target[2:] # Remove the colon at the end colon_index = ninja_target_name.find(':') if colon_index != -1: ninja_target_name = ninja_target_name[:colon_index] return os.path.join(ninja_target_name, 'BUILD.gn') def CreateAddDepsCommand(gn_target: str, missing_deps: List[str]) -> List[str]: # Normalize chrome_public_apk__java to chrome_public_apk. gn_target = gn_target.split('__', 1)[0] build_file_path = GnTargetToBuildFilePath(gn_target) return [ 'build/gn_editor', 'add', '--quiet', '--file', build_file_path, '--target', gn_target, '--deps' ] + missing_deps def ReplaceGmsPackageIfNeeded(target_name: str) -> str: if target_name.startswith( ('//third_party/android_deps:google_play_services_', '//clank/third_party/google3:google_play_services_')): return f'$google_play_services_package:{target_name.split(":")[1]}' return target_name def DisambiguateDeps(class_entries: List[ClassEntry]): def filter_if_not_empty(entries, filter_func): filtered_entries = [e for e in entries if filter_func(e)] return filtered_entries or entries # When some deps are preferred, ignore all other potential deps. class_entries = filter_if_not_empty(class_entries, lambda e: e.preferred_dep) # E.g. javax_annotation_jsr250_api_java. class_entries = filter_if_not_empty(class_entries, lambda e: 'jsr' in e.target) # Avoid suggesting subtargets when regular targets exist. class_entries = filter_if_not_empty(class_entries, lambda e: '__' not in e.target) # Swap out GMS package names if needed. class_entries = [ dataclasses.replace(e, target=ReplaceGmsPackageIfNeeded(e.target)) for e in class_entries ] # Convert to dict and then use list to get the keys back to remove dups and # keep order the same as before. class_entries = list({e: True for e in class_entries}) return class_entries