1#!/usr/bin/env python3 2# 3# Copyright 2013 The Chromium Authors 4# Use of this source code is governed by a BSD-style license that can be 5# found in the LICENSE file. 6"""Instruments classes and jar files. 7 8This script corresponds to the 'jacoco_instr' action in the Java build process. 9Depending on whether jacoco_instrument is set, the 'jacoco_instr' action will 10call the instrument command which accepts a jar and instruments it using 11jacococli.jar. 12 13""" 14 15import argparse 16import json 17import os 18import shutil 19import sys 20import zipfile 21 22from util import build_utils 23import action_helpers 24import zip_helpers 25 26 27# This should be same as recipe side token. See bit.ly/3STSPcE. 28INSTRUMENT_ALL_JACOCO_OVERRIDE_TOKEN = 'INSTRUMENT_ALL_JACOCO' 29 30 31def _AddArguments(parser): 32 """Adds arguments related to instrumentation to parser. 33 34 Args: 35 parser: ArgumentParser object. 36 """ 37 parser.add_argument( 38 '--root-build-dir', 39 required=True, 40 help='Path to build directory rooted at checkout dir e.g. //out/Release') 41 parser.add_argument('--input-path', 42 required=True, 43 help='Path to input file(s). Either the classes ' 44 'directory, or the path to a jar.') 45 parser.add_argument('--output-path', 46 required=True, 47 help='Path to output final file(s) to. Either the ' 48 'final classes directory, or the directory in ' 49 'which to place the instrumented/copied jar.') 50 parser.add_argument('--sources-json-file', 51 required=True, 52 help='File to create with the list of source directories ' 53 'and input path.') 54 parser.add_argument( 55 '--target-sources-file', 56 required=True, 57 help='File containing newline-separated .java and .kt paths') 58 parser.add_argument( 59 '--jacococli-jar', required=True, help='Path to jacococli.jar.') 60 parser.add_argument( 61 '--files-to-instrument', 62 help='Path to a file containing which source files are affected.') 63 64 65def _GetSourceDirsFromSourceFiles(source_files): 66 """Returns list of directories for the files in |source_files|. 67 68 Args: 69 source_files: List of source files. 70 71 Returns: 72 List of source directories. 73 """ 74 return list(set(os.path.dirname(source_file) for source_file in source_files)) 75 76 77def _CreateSourcesJsonFile(source_dirs, input_path, sources_json_file, src_root, 78 root_build_dir): 79 """Adds all normalized source directories and input path to 80 |sources_json_file|. 81 82 Args: 83 source_dirs: List of source directories. 84 input_path: The input path to non-instrumented class files. 85 sources_json_file: File into which to write the list of source directories 86 and input path. 87 src_root: Root which sources added to the file should be relative to. 88 root_build_dir: Build directory path rooted at checkout where 89 sources_json_file is generated e.g. //out/Release 90 91 Returns: 92 An exit code. 93 """ 94 src_root = os.path.abspath(src_root) 95 relative_sources = [] 96 for s in source_dirs: 97 abs_source = os.path.abspath(s) 98 if abs_source[:len(src_root)] != src_root: 99 print('Error: found source directory not under repository root: %s %s' % 100 (abs_source, src_root)) 101 return 1 102 rel_source = os.path.relpath(abs_source, src_root) 103 104 relative_sources.append(rel_source) 105 106 data = {} 107 data['source_dirs'] = relative_sources 108 data['input_path'] = [] 109 build_dir = os.path.join(src_root, root_build_dir[2:]) 110 data['output_dir'] = build_dir 111 if input_path: 112 data['input_path'].append(os.path.abspath(input_path)) 113 with open(sources_json_file, 'w') as f: 114 json.dump(data, f) 115 return 0 116 117 118def _GetAffectedClasses(jar_file, source_files): 119 """Gets affected classes by affected source files to a jar. 120 121 Args: 122 jar_file: The jar file to get all members. 123 source_files: The list of affected source files. 124 125 Returns: 126 A tuple of affected classes and unaffected members. 127 """ 128 with zipfile.ZipFile(jar_file) as f: 129 members = f.namelist() 130 131 affected_classes = [] 132 unaffected_members = [] 133 134 for member in members: 135 if not member.endswith('.class'): 136 unaffected_members.append(member) 137 continue 138 139 is_affected = False 140 index = member.find('$') 141 if index == -1: 142 index = member.find('.class') 143 for source_file in source_files: 144 if source_file.endswith( 145 (member[:index] + '.java', member[:index] + '.kt')): 146 affected_classes.append(member) 147 is_affected = True 148 break 149 if not is_affected: 150 unaffected_members.append(member) 151 152 return affected_classes, unaffected_members 153 154 155def _InstrumentClassFiles(instrument_cmd, 156 input_path, 157 output_path, 158 temp_dir, 159 affected_source_files=None): 160 """Instruments class files from input jar. 161 162 Args: 163 instrument_cmd: JaCoCo instrument command. 164 input_path: The input path to non-instrumented jar. 165 output_path: The output path to instrumented jar. 166 temp_dir: The temporary directory. 167 affected_source_files: The affected source file paths to input jar. 168 Default is None, which means instrumenting all class files in jar. 169 """ 170 affected_classes = None 171 unaffected_members = None 172 if affected_source_files: 173 affected_classes, unaffected_members = _GetAffectedClasses( 174 input_path, affected_source_files) 175 176 # Extract affected class files. 177 with zipfile.ZipFile(input_path) as f: 178 f.extractall(temp_dir, affected_classes) 179 180 instrumented_dir = os.path.join(temp_dir, 'instrumented') 181 182 # Instrument extracted class files. 183 instrument_cmd.extend([temp_dir, '--dest', instrumented_dir]) 184 build_utils.CheckOutput(instrument_cmd) 185 186 if affected_source_files and unaffected_members: 187 # Extract unaffected members to instrumented_dir. 188 with zipfile.ZipFile(input_path) as f: 189 f.extractall(instrumented_dir, unaffected_members) 190 191 # Zip all files to output_path 192 with action_helpers.atomic_output(output_path) as f: 193 zip_helpers.zip_directory(f, instrumented_dir) 194 195 196def _RunInstrumentCommand(parser): 197 """Instruments class or Jar files using JaCoCo. 198 199 Args: 200 parser: ArgumentParser object. 201 202 Returns: 203 An exit code. 204 """ 205 args = parser.parse_args() 206 207 source_files = [] 208 if args.target_sources_file: 209 source_files.extend(build_utils.ReadSourcesList(args.target_sources_file)) 210 211 with build_utils.TempDir() as temp_dir: 212 instrument_cmd = build_utils.JavaCmd() + [ 213 '-jar', args.jacococli_jar, 'instrument' 214 ] 215 216 if not args.files_to_instrument: 217 affected_source_files = None 218 else: 219 affected_files = build_utils.ReadSourcesList(args.files_to_instrument) 220 # Check if coverage recipe decided to instrument everything by overriding 221 # the try builder default setting(selective instrumentation). This can 222 # happen in cases like a DEPS roll of jacoco library 223 224 # Note: This token is preceded by ../../ because the paths to be 225 # instrumented are expected to be relative to the build directory. 226 # See _rebase_paths() at https://bit.ly/40oiixX 227 token = '../../' + INSTRUMENT_ALL_JACOCO_OVERRIDE_TOKEN 228 if token in affected_files: 229 affected_source_files = None 230 else: 231 source_set = set(source_files) 232 affected_source_files = [f for f in affected_files if f in source_set] 233 234 # Copy input_path to output_path and return if no source file affected. 235 if not affected_source_files: 236 shutil.copyfile(args.input_path, args.output_path) 237 # Create a dummy sources_json_file. 238 _CreateSourcesJsonFile([], None, args.sources_json_file, 239 build_utils.DIR_SOURCE_ROOT, 240 args.root_build_dir) 241 return 0 242 _InstrumentClassFiles(instrument_cmd, args.input_path, args.output_path, 243 temp_dir, affected_source_files) 244 245 source_dirs = _GetSourceDirsFromSourceFiles(source_files) 246 # TODO(GYP): In GN, we are passed the list of sources, detecting source 247 # directories, then walking them to re-establish the list of sources. 248 # This can obviously be simplified! 249 _CreateSourcesJsonFile(source_dirs, args.input_path, args.sources_json_file, 250 build_utils.DIR_SOURCE_ROOT, args.root_build_dir) 251 252 return 0 253 254 255def main(): 256 parser = argparse.ArgumentParser() 257 _AddArguments(parser) 258 _RunInstrumentCommand(parser) 259 260 261if __name__ == '__main__': 262 sys.exit(main()) 263