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