OLD | NEW |
1 #!/usr/bin/python | 1 #!/usr/bin/python |
2 # Copyright 2015 The Chromium Authors. All rights reserved. | 2 # Copyright 2015 The Chromium Authors. All rights reserved. |
3 # Use of this source code is governed by a BSD-style license that can be | 3 # Use of this source code is governed by a BSD-style license that can be |
4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
5 | 5 |
6 """Generates incremental code coverage reports for Java code in Chromium.""" | 6 """Generates incremental code coverage reports for Java code in Chromium. |
7 | 7 |
| 8 Usage: |
| 9 |
| 10 build/android/coverage.py -v --out <output file path> --emma-dir |
| 11 <EMMA file directory> --lines-for-coverage-file |
| 12 <path to file containing lines for coverage> |
| 13 |
| 14 Creates a JSON representation of the overall and file coverage stats and saves |
| 15 this information to the specified output file. |
| 16 """ |
| 17 |
| 18 import argparse |
8 import collections | 19 import collections |
| 20 import json |
| 21 import logging |
9 import os | 22 import os |
| 23 import re |
| 24 import sys |
10 from xml.etree import ElementTree | 25 from xml.etree import ElementTree |
11 | 26 |
| 27 from pylib.utils import run_tests_helper |
| 28 |
12 NOT_EXECUTABLE = -1 | 29 NOT_EXECUTABLE = -1 |
13 NOT_COVERED = 0 | 30 NOT_COVERED = 0 |
14 COVERED = 1 | 31 COVERED = 1 |
15 PARTIALLY_COVERED = 2 | 32 PARTIALLY_COVERED = 2 |
16 | 33 |
17 # Coverage information about a single line of code. | 34 # Coverage information about a single line of code. |
18 LineCoverage = collections.namedtuple( | 35 LineCoverage = collections.namedtuple( |
19 'LineCoverage', | 36 'LineCoverage', |
20 ['lineno', 'source', 'covered_status', 'fractional_line_coverage']) | 37 ['lineno', 'source', 'covered_status', 'fractional_line_coverage']) |
21 | 38 |
(...skipping 38 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
60 1. Line number | 77 1. Line number |
61 2. Line of source code | 78 2. Line of source code |
62 3. Coverage status (c, z, or p) | 79 3. Coverage status (c, z, or p) |
63 4. Fractional coverage value (% out of 100 if PARTIALLY_COVERED) | 80 4. Fractional coverage value (% out of 100 if PARTIALLY_COVERED) |
64 """ | 81 """ |
65 # Selector to match all <a> elements within the rows that are in the table | 82 # Selector to match all <a> elements within the rows that are in the table |
66 # that displays all of the different packages. | 83 # that displays all of the different packages. |
67 _XPATH_SELECT_PACKAGE_ELEMENTS = './/BODY/TABLE[4]/TR/TD/A' | 84 _XPATH_SELECT_PACKAGE_ELEMENTS = './/BODY/TABLE[4]/TR/TD/A' |
68 | 85 |
69 # Selector to match all <a> elements within the rows that are in the table | 86 # Selector to match all <a> elements within the rows that are in the table |
70 # that displays all of the different packages within a class. | 87 # that displays all of the different classes within a package. |
71 _XPATH_SELECT_CLASS_ELEMENTS = './/BODY/TABLE[3]/TR/TD/A' | 88 _XPATH_SELECT_CLASS_ELEMENTS = './/BODY/TABLE[3]/TR/TD/A' |
72 | 89 |
73 # Selector to match all <tr> elements within the table containing Java source | 90 # Selector to match all <tr> elements within the table containing Java source |
74 # code in an EMMA HTML file. | 91 # code in an EMMA HTML file. |
75 _XPATH_SELECT_LOC = './/BODY/TABLE[4]/TR' | 92 _XPATH_SELECT_LOC = './/BODY/TABLE[4]/TR' |
76 | 93 |
77 # Children of HTML elements are represented as a list in ElementTree. These | 94 # Children of HTML elements are represented as a list in ElementTree. These |
78 # constants represent list indices corresponding to relevant child elements. | 95 # constants represent list indices corresponding to relevant child elements. |
79 | 96 |
80 # Child 1 contains percentage covered for a line. | 97 # Child 1 contains percentage covered for a line. |
(...skipping 70 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
151 | 168 |
152 Returns: | 169 Returns: |
153 A dict mapping string representation of Java packages (with class | 170 A dict mapping string representation of Java packages (with class |
154 names appended) to the corresponding file paths of EMMA HTML files. | 171 names appended) to the corresponding file paths of EMMA HTML files. |
155 """ | 172 """ |
156 # These <a> elements contain each package name and the path of the file | 173 # These <a> elements contain each package name and the path of the file |
157 # where all classes within said package are listed. | 174 # where all classes within said package are listed. |
158 package_link_elements = self._FindElements( | 175 package_link_elements = self._FindElements( |
159 self._index_path, self._XPATH_SELECT_PACKAGE_ELEMENTS) | 176 self._index_path, self._XPATH_SELECT_PACKAGE_ELEMENTS) |
160 # Maps file path of package directory (EMMA generated) to package name. | 177 # Maps file path of package directory (EMMA generated) to package name. |
161 # Ex. emma_dir/f.html: org.chromium.chrome. | 178 # Example: emma_dir/f.html: org.chromium.chrome. |
162 package_links = { | 179 package_links = { |
163 os.path.join(self._base_dir, link.attrib['HREF']): link.text | 180 os.path.join(self._base_dir, link.attrib['HREF']): link.text |
164 for link in package_link_elements if 'HREF' in link.attrib | 181 for link in package_link_elements if 'HREF' in link.attrib |
165 } | 182 } |
166 | 183 |
167 package_to_emma = {} | 184 package_to_emma = {} |
168 for package_emma_file_path, package_name in package_links.iteritems(): | 185 for package_emma_file_path, package_name in package_links.iteritems(): |
169 # These <a> elements contain each class name in the current package and | 186 # These <a> elements contain each class name in the current package and |
170 # the path of the file where the coverage info is stored for each class. | 187 # the path of the file where the coverage info is stored for each class. |
171 coverage_file_link_elements = self._FindElements( | 188 coverage_file_link_elements = self._FindElements( |
172 package_emma_file_path, self._XPATH_SELECT_CLASS_ELEMENTS) | 189 package_emma_file_path, self._XPATH_SELECT_CLASS_ELEMENTS) |
173 | 190 |
174 for coverage_file_element in coverage_file_link_elements: | 191 for class_name_element in coverage_file_link_elements: |
175 emma_coverage_file_path = os.path.join( | 192 emma_coverage_file_path = os.path.join( |
176 self._emma_files_path, coverage_file_element.attrib['HREF']) | 193 self._emma_files_path, class_name_element.attrib['HREF']) |
177 full_package_name = '%s.%s' % (package_name, coverage_file_element.text) | 194 full_package_name = '%s.%s' % (package_name, class_name_element.text) |
178 package_to_emma[full_package_name] = emma_coverage_file_path | 195 package_to_emma[full_package_name] = emma_coverage_file_path |
179 | 196 |
180 return package_to_emma | 197 return package_to_emma |
181 | 198 |
182 def _FindElements(self, file_path, xpath_selector): | 199 def _FindElements(self, file_path, xpath_selector): |
183 """Reads a HTML file and performs an XPath match. | 200 """Reads a HTML file and performs an XPath match. |
184 | 201 |
185 Args: | 202 Args: |
186 file_path: String representing the path to the HTML file. | 203 file_path: String representing the path to the HTML file. |
187 xpath_selector: String representing xpath search pattern. | 204 xpath_selector: String representing xpath search pattern. |
188 | 205 |
189 Returns: | 206 Returns: |
190 A list of ElementTree.Elements matching the given XPath selector. | 207 A list of ElementTree.Elements matching the given XPath selector. |
191 Returns an empty list if there is no match. | 208 Returns an empty list if there is no match. |
192 """ | 209 """ |
193 with open(file_path) as f: | 210 with open(file_path) as f: |
194 file_contents = f.read().decode('ISO-8859-1').encode('UTF-8') | 211 file_contents = f.read().decode('ISO-8859-1').encode('UTF-8') |
195 root = ElementTree.fromstring(file_contents) | 212 root = ElementTree.fromstring(file_contents) |
196 return root.findall(xpath_selector) | 213 return root.findall(xpath_selector) |
| 214 |
| 215 |
| 216 class _EmmaCoverageStats(object): |
| 217 """Computes code coverage stats for Java code using the coverage tool EMMA. |
| 218 |
| 219 This class provides an API that allows users to capture absolute code coverage |
| 220 and code coverage on a subset of lines for each Java source file. Coverage |
| 221 reports are generated in JSON format. |
| 222 """ |
| 223 # Regular expression to get package name from Java package statement. |
| 224 RE_PACKAGE_MATCH_GROUP = 'package' |
| 225 RE_PACKAGE = re.compile(r'package (?P<%s>[\w.]*);' % RE_PACKAGE_MATCH_GROUP) |
| 226 |
| 227 def __init__(self, emma_file_base_dir, files_for_coverage): |
| 228 """Initialize _EmmaCoverageStats. |
| 229 |
| 230 Args: |
| 231 emma_file_base_dir: String representing the path to the base directory |
| 232 where EMMA HTML coverage files are stored, i.e. parent of index.html. |
| 233 files_for_coverage: A list of Java source code file paths to get EMMA |
| 234 coverage for. |
| 235 """ |
| 236 self._emma_parser = _EmmaHtmlParser(emma_file_base_dir) |
| 237 self._source_to_emma = self._GetSourceFileToEmmaFileDict(files_for_coverage) |
| 238 |
| 239 def GetCoverageDict(self, lines_for_coverage): |
| 240 """Returns a dict containing detailed coverage information. |
| 241 |
| 242 Gets detailed coverage stats for each file specified in the |
| 243 |lines_for_coverage| dict and the total incremental number of lines covered |
| 244 and executable for all files in |lines_for_coverage|. |
| 245 |
| 246 Args: |
| 247 lines_for_coverage: A dict mapping Java source file paths to lists of line |
| 248 numbers. |
| 249 |
| 250 Returns: |
| 251 A dict containing coverage stats for the given dict of files and lines. |
| 252 Contains absolute coverage stats for each file, coverage stats for each |
| 253 file's lines specified in |lines_for_coverage|, line by line coverage |
| 254 for each file, and overall coverage stats for the lines specified in |
| 255 |lines_for_coverage|. |
| 256 """ |
| 257 file_coverage = {} |
| 258 for file_path, line_numbers in lines_for_coverage.iteritems(): |
| 259 file_coverage[file_path] = self.GetCoverageDictForFile( |
| 260 file_path, line_numbers) |
| 261 |
| 262 covered_statuses = [s['incremental'] for s in file_coverage.itervalues()] |
| 263 num_covered_lines = sum(s['covered'] for s in covered_statuses) |
| 264 num_total_lines = sum(s['total'] for s in covered_statuses) |
| 265 return { |
| 266 'files': file_coverage, |
| 267 'patch': { |
| 268 'incremental': { |
| 269 'covered': num_covered_lines, |
| 270 'total': num_total_lines |
| 271 } |
| 272 } |
| 273 } |
| 274 |
| 275 def GetCoverageDictForFile(self, file_path, line_numbers): |
| 276 """Returns a dict containing detailed coverage info for the given file. |
| 277 |
| 278 Args: |
| 279 file_path: The path to the Java source file that we want to create the |
| 280 coverage dict for. |
| 281 line_numbers: A list of integer line numbers to retrieve additional stats |
| 282 for. |
| 283 |
| 284 Returns: |
| 285 A dict containing absolute, incremental, and line by line coverage for |
| 286 a file. |
| 287 """ |
| 288 total_line_coverage = self._GetLineCoverageForFile(file_path) |
| 289 incremental_line_coverage = [line for line in total_line_coverage |
| 290 if line.lineno in line_numbers] |
| 291 line_by_line_coverage = [ |
| 292 { |
| 293 'line': line.source, |
| 294 'coverage': line.covered_status, |
| 295 'changed': line.lineno in line_numbers, |
| 296 } |
| 297 for line in total_line_coverage |
| 298 ] |
| 299 total_covered_lines, total_lines = ( |
| 300 self.GetSummaryStatsForLines(total_line_coverage)) |
| 301 incremental_covered_lines, incremental_total_lines = ( |
| 302 self.GetSummaryStatsForLines(incremental_line_coverage)) |
| 303 |
| 304 file_coverage_stats = { |
| 305 'absolute': { |
| 306 'covered': total_covered_lines, |
| 307 'total': total_lines |
| 308 }, |
| 309 'incremental': { |
| 310 'covered': incremental_covered_lines, |
| 311 'total': incremental_total_lines |
| 312 }, |
| 313 'source': line_by_line_coverage, |
| 314 } |
| 315 return file_coverage_stats |
| 316 |
| 317 def GetSummaryStatsForLines(self, line_coverage): |
| 318 """Gets summary stats for a given list of LineCoverage objects. |
| 319 |
| 320 Args: |
| 321 line_coverage: A list of LineCoverage objects. |
| 322 |
| 323 Returns: |
| 324 A tuple containing the number of lines that are covered and the total |
| 325 number of lines that are executable, respectively |
| 326 """ |
| 327 partially_covered_sum = 0 |
| 328 covered_status_totals = {COVERED: 0, NOT_COVERED: 0, PARTIALLY_COVERED: 0} |
| 329 for line in line_coverage: |
| 330 status = line.covered_status |
| 331 if status == NOT_EXECUTABLE: |
| 332 continue |
| 333 covered_status_totals[status] += 1 |
| 334 if status == PARTIALLY_COVERED: |
| 335 partially_covered_sum += line.fractional_line_coverage |
| 336 |
| 337 total_covered = covered_status_totals[COVERED] + partially_covered_sum |
| 338 total_lines = sum(covered_status_totals.values()) |
| 339 return total_covered, total_lines |
| 340 |
| 341 def _GetLineCoverageForFile(self, file_path): |
| 342 """Gets a list of LineCoverage objects corresponding to the given file path. |
| 343 |
| 344 Args: |
| 345 file_path: String representing the path to the Java source file. |
| 346 |
| 347 Returns: |
| 348 A list of LineCoverage objects, or None if there is no EMMA file |
| 349 for the given Java source file. |
| 350 """ |
| 351 if file_path in self._source_to_emma: |
| 352 emma_file = self._source_to_emma[file_path] |
| 353 return self._emma_parser.GetLineCoverage(emma_file) |
| 354 else: |
| 355 logging.warning( |
| 356 'No code coverage data for %s, skipping.', file_path) |
| 357 return None |
| 358 |
| 359 def _GetSourceFileToEmmaFileDict(self, files): |
| 360 """Gets a dict used to correlate Java source files with EMMA HTML files. |
| 361 |
| 362 This method gathers the information needed to correlate EMMA HTML |
| 363 files with Java source files. EMMA XML and plain text reports do not provide |
| 364 line by line coverage data, so HTML reports must be used instead. |
| 365 Unfortunately, the HTML files that are created are given garbage names |
| 366 (i.e 1.html) so we need to manually correlate EMMA HTML files |
| 367 with the original Java source files. |
| 368 |
| 369 Args: |
| 370 files: A list of file names for which coverage information is desired. |
| 371 |
| 372 Returns: |
| 373 A dict mapping Java source file paths to EMMA HTML file paths. |
| 374 """ |
| 375 # Maps Java source file paths to package names. |
| 376 # Example: /usr/code/file.java -> org.chromium.file.java. |
| 377 source_to_package = {} |
| 378 for file_path in files: |
| 379 package = self.GetPackageNameFromFile(file_path) |
| 380 if package: |
| 381 source_to_package[file_path] = package |
| 382 else: |
| 383 logging.warning("Skipping %s because it doesn\'t have a package " |
| 384 "statement.", file_path) |
| 385 |
| 386 # Maps package names to EMMA report HTML files. |
| 387 # Example: org.chromium.file.java -> out/coverage/1a.html. |
| 388 package_to_emma = self._emma_parser.GetPackageNameToEmmaFileDict() |
| 389 # Finally, we have a dict mapping Java file paths to EMMA report files. |
| 390 # Example: /usr/code/file.java -> out/coverage/1a.html. |
| 391 source_to_emma = {source: package_to_emma.get(package) |
| 392 for source, package in source_to_package.iteritems()} |
| 393 return source_to_emma |
| 394 |
| 395 @staticmethod |
| 396 def NeedsCoverage(file_path): |
| 397 """Checks to see if the file needs to be analyzed for code coverage. |
| 398 |
| 399 Args: |
| 400 file_path: A string representing path to the file. |
| 401 |
| 402 Returns: |
| 403 True for Java files that exist, False for all others. |
| 404 """ |
| 405 if os.path.splitext(file_path)[1] == '.java' and os.path.exists(file_path): |
| 406 return True |
| 407 else: |
| 408 logging.debug( |
| 409 'Skipping file %s, cannot compute code coverage.', file_path) |
| 410 return False |
| 411 |
| 412 @staticmethod |
| 413 def GetPackageNameFromFile(file_path): |
| 414 """Gets the full package name including the file name for a given file path. |
| 415 |
| 416 Args: |
| 417 file_path: String representing the path to the Java source file. |
| 418 |
| 419 Returns: |
| 420 A string representing the full package name with file name appended or |
| 421 None if there is no package statement in the file. |
| 422 """ |
| 423 with open(file_path) as f: |
| 424 file_content = f.read() |
| 425 package_match = re.search(_EmmaCoverageStats.RE_PACKAGE, file_content) |
| 426 if package_match: |
| 427 package = package_match.group(_EmmaCoverageStats.RE_PACKAGE_MATCH_GROUP) |
| 428 file_name = os.path.basename(file_path) |
| 429 return '%s.%s' % (package, file_name) |
| 430 else: |
| 431 return None |
| 432 |
| 433 |
| 434 def GenerateCoverageReport(line_coverage_file, out_file_path, coverage_dir): |
| 435 """Generates a coverage report for a given set of lines. |
| 436 |
| 437 Writes the results of the coverage analysis to the file specified by |
| 438 |out_file_path|. |
| 439 |
| 440 Args: |
| 441 line_coverage_file: The path to a file which contains a dict mapping file |
| 442 names to lists of line numbers. Example: {file1: [1, 2, 3], ...} means |
| 443 that we should compute coverage information on lines 1 - 3 for file1. |
| 444 out_file_path: A string representing the location to write the JSON report. |
| 445 coverage_dir: A string representing the file path where the EMMA |
| 446 HTML coverage files are located (i.e. folder where index.html is located). |
| 447 """ |
| 448 with open(line_coverage_file) as f: |
| 449 potential_files_for_coverage = json.load(f) |
| 450 files_for_coverage = {f: lines |
| 451 for f, lines in potential_files_for_coverage.iteritems() |
| 452 if _EmmaCoverageStats.NeedsCoverage(f)} |
| 453 if not files_for_coverage: |
| 454 logging.info('No Java files requiring coverage were included in %s.', |
| 455 line_coverage_file) |
| 456 return |
| 457 |
| 458 code_coverage = _EmmaCoverageStats(coverage_dir, files_for_coverage.keys()) |
| 459 coverage_results = code_coverage.GetCoverageDict( |
| 460 files_for_coverage) |
| 461 |
| 462 with open(out_file_path, 'w+') as out_status_file: |
| 463 json.dump(coverage_results, out_status_file) |
| 464 |
| 465 |
| 466 def main(): |
| 467 argparser = argparse.ArgumentParser() |
| 468 argparser.add_argument('--out', required=True, type=str, |
| 469 help='Report output file path.') |
| 470 argparser.add_argument('--emma-dir', required=True, type=str, |
| 471 help='EMMA HTML report directory.') |
| 472 argparser.add_argument('--lines-for-coverage-file', required=True, type=str, |
| 473 help='File containing a JSON object. Should contain a ' |
| 474 'dict mapping file names to lists of line numbers of ' |
| 475 'code for which coverage information is desired.') |
| 476 argparser.add_argument('-v', '--verbose', action='count', |
| 477 help='Print verbose log information.') |
| 478 args = argparser.parse_args() |
| 479 run_tests_helper.SetLogLevel(args.verbose) |
| 480 GenerateCoverageReport(args.lines_for_coverage_file, args.out, args.emma_dir) |
| 481 |
| 482 |
| 483 if __name__ == '__main__': |
| 484 sys.exit(main()) |
OLD | NEW |