Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(232)

Side by Side Diff: build/android/coverage.py

Issue 1216033009: Updated script to capture useful coverage stats. (Closed) Base URL: https://chromium.googlesource.com/chromium/src.git@master
Patch Set: Created 5 years, 5 months ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
« no previous file with comments | « no previous file | build/android/coverage_test.py » ('j') | no next file with comments »
Toggle Intra-line Diffs ('i') | Expand Comments ('e') | Collapse Comments ('c') | Show Comments Hide Comments ('s')
OLDNEW
(Empty)
1 #!/usr/bin/python
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
4 # found in the LICENSE file.
5
6 """Generates incremental code coverage reports for Java code in Chromium.
7
8 Usage:
9
10 build/android/coverage.py -v --out <output file path> --emma-dir
11 <EMMA file directory> --lines-for-coverage
12 <path to file containing lines for coverage>
13
14 Creates a JSON representation of overall and file coverage stats and saves
15 this information to the specified output file.
16 """
17
18 import argparse
19 import json
20 import logging
21 import os
22 import re
23 import sys
24 from lxml import html
25
26 from pylib.utils import run_tests_helper
27
28
29 class LineCoverage(object):
30 """Coverage information about a single line of code."""
31
32 NOT_EXECUTABLE = -1
33 NOT_COVERED = 0
34 COVERED = 1
35 PARTIALLY_COVERED = 2
36
37 def __init__(self, lineno, source, covered_status, fractional_line_coverage):
38 """Initializes LineCoverage.
39
40 Args:
41 lineno: Integer line number.
42 source: A string containing the original line of source code.
43 covered_status: The covered status of the line.
44 fractional_line_coverage: The fractional value representing the fraction
45 of instructions executed for a given line of code. Should be a floating
46 point number between [0.0 - 1.0].
47 """
48 self.lineno = lineno
49 self.source = source
50 self.covered_status = covered_status
51 self.fractional_line_coverage = fractional_line_coverage
52
53
54 class _EmmaHtmlParser(object):
55 """Encapsulates HTML file parsing operations.
56
57 This class contains all operations related to parsing HTML files that were
58 produced using the EMMA code coverage tool. It uses the lxml module for
59 parsing.
60
61 Example HTML:
62
63 To get package links:
64 <a href="_files/1.html">org.chromium.chrome</a>
65 This is returned by the selector |XPATH_SELECT_PACKAGE_ELEMENTS|.
66
67 To get class links:
68 <a href="1e.html">DoActivity.java</a>
69 This is returned by the selector |XPATH_SELECT_CLASS_ELEMENTS|.
70
71 To get coverage information:
72 <tr class="p">
73 <td class="l" title="78% line coverage (7 out of 9)">108</td>
74 <td title="78% line coverage (7 out of 9 instructions)">
75 if (index < 0 || index = mSelectors.size()) index = 0;</td>
76 </tr>
77 <tr>
78 <td class="l">109</td>
79 <td> </td>
80 </tr>
81 <tr class="c">
82 <td class="l">110</td>
83 <td> if (mSelectors.get(index) != null) {</td>
84 </tr>
85 <tr class="z">
86 <td class="l">111</td>
87 <td> for (int i = 0; i < mSelectors.size(); i++) {</td>
88 </tr>
89 Each <tr> element is returned by the selector |XPATH_SELECT_LOC|.
90
91 We can parse this to get:
92 1. Line number
93 2. Line of source code
94 3. Coverage status (c, z, or p)
95 4. Fractional coverage value (% out of 100 if PARTIALLY_COVERED)
96 """
97 # Selector to match all <a> elements within the rows that are in the table
98 # that displays all of the different packages.
99 _XPATH_SELECT_PACKAGE_ELEMENTS = '/html/body/table[4]/tr[*]/td/a'
100
101 # Selector to match all <a> elements within the rows that are in the table
102 # that displays all of the different packages within a class.
103 _XPATH_SELECT_CLASS_ELEMENTS = '/html/body/table[3]/tr[*]/td/a'
104
105 # Selector to match all <tr> elements within the table containing Java source
106 # code in an EMMA HTML file.
107 _XPATH_SELECT_LOC = '/html/body/table[4]/tr'
108
109 # Children of HTML elements are represented as a list in lxml. These constants
110 # represent list indices corresponding to relevant child elements.
111
112 # Technically both child 1 and 2 contain the percentage covered for a line.
113 _ELEMENT_PERCENT_COVERED = 1
114
115 # The second child contains the original line of source code.
116 _ELEMENT_CONTAINING_SOURCE_CODE = 1
117
118 # The first child contains the line number.
119 _ELEMENT_CONTAINING_LINENO = 0
120
121 # Maps CSS class names to corresponding coverage constants.
122 _CSS_TO_STATUS = {'c': LineCoverage.COVERED,
123 'p': LineCoverage.PARTIALLY_COVERED,
124 'z': LineCoverage.NOT_COVERED}
125
126 # UTF-8 no break space.
127 _NO_BREAK_SPACE = '\xc2\xa0'
128
129 def __init__(self, emma_file_base_dir):
130 """Initializes _EmmaHtmlParser.
131
132 Args:
133 emma_file_base_dir: Path to the location where EMMA report files are
134 stored. Should be where index.html is stored.
135 """
136 self._base_dir = emma_file_base_dir
137 self._emma_files_path = os.path.join(self._base_dir, '_files')
138 self._index_path = os.path.join(self._base_dir, 'index.html')
139
140 def GetLineCoverage(self, emma_file_path):
141 """Returns a list of LineCoverage objects for the given EMMA HTML file.
142
143 Args:
144 emma_file_path: String representing the path to the EMMA HTML file.
145
146 Returns:
147 A list of LineCoverage objects.
148 """
149 def get_status(tr_element):
150 """Returns coverage status for a <tr> element containing coverage info."""
151 if 'class' not in tr_element.attrib:
152 status = LineCoverage.NOT_EXECUTABLE
153 else:
154 status = self._CSS_TO_STATUS.get(
155 tr_element.attrib['class'], LineCoverage.NOT_EXECUTABLE)
156 return status
157
158 def get_fractional_line_coverage(tr_element, status):
159 """Returns coverage value for a <tr> element containing coverage info."""
160 # If line is partially covered, parse the <td> tag to get the
161 # coverage percent.
162 if status == LineCoverage.PARTIALLY_COVERED:
163 title_attribute = (
164 tr_element[self._ELEMENT_PERCENT_COVERED].attrib['title'])
165 # Parse string that contains percent covered: "83% line coverage ,,,".
166 percent_covered = title_attribute.split('%')[0]
167 fractional_coverage_value = int(percent_covered) / 100.0
168 else:
169 fractional_coverage_value = 1.0
170 return fractional_coverage_value
171
172 def get_lineno(tr_element):
173 """Returns line number for a <tr> element containing coverage info."""
174 lineno_element = tr_element[self._ELEMENT_CONTAINING_LINENO]
175 # Handles oddly formatted HTML (where there is an extra <a> tag).
176 lineno = int(lineno_element.text or
177 lineno_element[self._ELEMENT_CONTAINING_LINENO].text)
178 return lineno
179
180 def get_source_code(tr_element):
181 """Returns Java source for a <tr> element containing coverage info."""
182 raw_source = tr_element[self._ELEMENT_CONTAINING_SOURCE_CODE].text
183 utf8_source = raw_source.encode('UTF-8')
184 readable_source = utf8_source.replace(self._NO_BREAK_SPACE, ' ')
185 return readable_source
186
187 line_tr_elements = self._FindElements(emma_file_path,
188 _path=self._XPATH_SELECT_LOC)
189 line_coverage = []
190 for tr in line_tr_elements:
191 coverage_status = get_status(tr)
192 fractional_coverage = get_fractional_line_coverage(tr, coverage_status)
193 lineno = get_lineno(tr)
194 source = get_source_code(tr)
195 line = LineCoverage(lineno, source, coverage_status, fractional_coverage)
196 line_coverage.append(line)
197
198 return line_coverage
199
200 def GetPackageNameToEmmaFileDict(self):
201 """Returns a dict mapping Java packages to EMMA HTML coverage files.
202
203 Parses the EMMA index.html file to get a list of packages, then parses each
204 package HTML file to get a list of classes for that package, and creates
205 a dict with this info.
206
207 Returns:
208 A dict mapping string representation of Java packages (with class
209 names appended) to the corresponding file paths of EMMA HTML files.
210 """
211 # The <a> elements that contain each package name and the path of the file
212 # where all classes within said package are listed.
213 package_a_elements = self._FindElements(
214 self._index_path, _path=self._XPATH_SELECT_PACKAGE_ELEMENTS)
215 # Maps file path of package directory (EMMA generated) to package name.
216 # Ex. emma_dir/f.html: org.chromium.chrome.
217 package_links = {os.path.join(self._base_dir, link.attrib['href']):
218 link.text
219 for link in package_a_elements if 'href' in link.attrib}
220
221 package_to_emma = {}
222 for package_emma_file_path, package_name in package_links.iteritems():
223 # The <a> elements that contain each class name in the current package and
224 # the path of the file where the coverage info is stored for each class.
225 coverage_file_a_elements = self._FindElements(
226 package_emma_file_path,
227 _path=self._XPATH_SELECT_CLASS_ELEMENTS)
228
229 for coverage_file_element in coverage_file_a_elements:
230 emma_file_path = os.path.join(self._emma_files_path,
231 coverage_file_element.attrib['href'])
232 full_package_name = package_name + '.' + coverage_file_element.text
233 package_to_emma[full_package_name] = emma_file_path
234
235 return package_to_emma
236
237 def _FindElements(self, file_path, **kwargs):
238 """Reads a HTML file and performs an XPath match.
239
240 Args:
241 file_path: String representing the path to the HTML file.
242 **kwargs: Keyword arguments for XPath match.
243
244 Returns:
245 A list of lxml.html.HtmlElements matching the given XPath selector.
246 Returns an empty list if there is no match.
247 """
248 try:
249 with open(file_path) as f:
250 file_contents = f.read().decode('ISO-8859-1')
251 root = html.fromstring(file_contents)
252 return root.xpath(**kwargs)
253 except IOError:
254 return []
255
256
257 class _EmmaCoverageStats(object):
258 """Encapsulates coverage operations related to EMMA files.
mikecase (-- gone --) 2015/07/27 18:45:52 Probably change the description to be more about s
estevenson1 2015/07/30 02:34:09 Done.
259
260 This class provides an API that allows users to capture absolute code coverage
261 and code coverage on a set of lines for each file.
262
263 Additionally, this class stores the information needed to correlate EMMA HTML
mikecase (-- gone --) 2015/07/27 18:45:52 I like this comment but it might not belong here.
estevenson1 2015/07/30 02:34:09 Done.
264 files with Java source files. EMMA XML and plain text reports do not provide
265 line by line coverage data, so HTML reports must be used instead.
266 Unfortunately, the HTML files that are created are given garbage names
267 (i.e 1.html) so we need to manually correlate EMMA HTML files
268 with the original Java source files.
269
270 Attributes:
271 _emma_parser: An _EmmaHtmlParser for reading EMMA HTML files.
272 _source_to_emma: A dict mapping Java source files to EMMA HTML files.
273 """
274 # Regular expression to get package name from Java package statement.
275 RE_PACKAGE = re.compile(r'package ([\w.]*);')
276
277 def __init__(self, emma_file_base_dir, files_for_coverage):
278 """Initialize _EmmaCoverageStats.
279
280 Args:
281 emma_file_base_dir: String representing the path to the base directory
282 where EMMA HTML coverage files are stored, i.e. parent of index.html.
283 files_for_coverage: A list of Java file paths to get EMMA coverage for.
284 """
285 self._emma_parser = _EmmaHtmlParser(emma_file_base_dir)
286 self._source_to_emma = self._GetSourceFileToEmmaFileDict(files_for_coverage)
287
288 def GetCoverageDictForFiles(self, lines_for_coverage):
289 """Returns a dict containing detailed coverage info for all files.
290
291 Args:
292 lines_for_coverage: A dict mapping Java source file paths to lists of
293 Integers representing a set of lines to find additional coverage
294 stats for.
295
296 Returns:
297 A dict containing coverage stats for the given dict of files and lines.
298 Contains absolute coverage stats for each file, coverage stats for each
299 file's lines specified in |lines_for_coverage|, and overall coverage
300 stats for the lines specified in |lines_for_coverage|.
301 """
302 stats = {}
303 for file_name, lines in lines_for_coverage.iteritems():
304 # Get a list of LineCoverage objects for the current file.
305 line_coverage = self._GetCoverageStatusForFile(file_name)
306 # If there was an error with EMMA file generation, |line_coverage| could
307 # be None.
308 # TODO(estevenson): Find a better way to handle EMMA errors.
309 if not line_coverage:
310 continue
311 stats[file_name] = self.GetCoverageReportForLines(line_coverage, lines)
312
313 statuses = [s['incremental'] for s in stats.itervalues()]
314 covered = sum(s['covered'] for s in statuses)
315 total = sum(s['total'] for s in statuses)
316 return {
317 'files': stats,
318 'patch': {
319 'incremental': {
320 'covered': covered,
321 'total': total
322 }
323 }
324 }
325
326 def GetCoverageReportForLines(self, line_coverage, lines):
327 """Gets code coverage stats for a given set of LineCoverage objects.
328
329 Args:
330 line_coverage: A list of LineCoverage objects holding coverage state
331 of each line.
332 lines: A list of lines to retrieve additional stats for.
333
334 Returns:
335 A dict containing absolute, incremental, and line by line coverage for
336 a file.
337 """
338 line_by_line_coverage = [
339 {
340 'line': line.source,
341 'coverage': line.covered_status,
342 'changed': line.lineno in lines,
343 }
344 for line in line_coverage
345 ]
346 (total_covered_lines, total_lines) = self._GetStatsForLines(line_coverage)
347 (incremental_covered_lines, incremental_total_lines) = (
348 self._GetStatsForLines(line_coverage, lines))
349
350 file_coverage_stats = {
351 'absolute': {
352 'covered': total_covered_lines,
353 'total': total_lines
354 },
355 'incremental': {
356 'covered': incremental_covered_lines,
357 'total': incremental_total_lines
358 },
359 'source': line_by_line_coverage,
360 }
361 return file_coverage_stats
362
363 def _GetCoverageStatusForFile(self, file_path):
364 """Gets a list LineCoverage objects corresponding to the given file.
365
366 Args:
367 file_path: String representing the path to the Java source file.
368
369 Returns:
370 A list of LineCoverage objects, or None if there is no EMMA file
371 for the given file.
372 """
373 if file_path in self._source_to_emma:
374 emma_file = self._source_to_emma[file_path]
375 return self._emma_parser.GetLineCoverage(emma_file)
376 else:
377 logging.warning(
378 'No code coverage data for %s, skipping.', file_path)
379 return None
380
381 def _GetSourceFileToEmmaFileDict(self, files):
382 """Gets a dict used to correlate Java source files with EMMA HTML files.
383
384 Args:
385 files: A list of file names for which coverage information is desired.
386
387 Returns:
388 A dict mapping Java source file paths to EMMA HTML file paths.
389 """
390 source_to_package = {}
391 for file_path in files:
392 package = self.GetPackageNameFromFile(file_path)
393 if package and os.path.exists(file_path):
394 source_to_package[file_path] = package
395 else:
396 logging.warning('Skipping %s because it doesn\'t have a package '
397 'statement or it doesn\'t exist.', file_path)
398 package_to_emma = self._emma_parser.GetPackageNameToEmmaFileDict()
399 source_to_emma = {source: package_to_emma.get(package, None)
400 for source, package in source_to_package.iteritems()}
401 return source_to_emma
402
403 def _GetStatsForLines(self, line_coverage, line_numbers=None):
404 """Gets coverage stats for a list of LineCoverage objects and line numbers.
405
406 Args:
407 line_coverage: A list of LineCoverage objects representing the coverage
408 status of each line.
409 line_numbers: An optional list of Integers representing changed lines.
410 If not specified, the method will return coverage stats for all lines.
411
412 Returns:
413 a dict containing the incremental coverage stats for the given
414 |file_path|: (total added lines covered, total lines added).
415 """
416 if not line_numbers:
417 line_numbers = range(1, len(line_coverage) + 1)
418 covered_count = 0
419 not_covered_count = 0
420 partially_covered_count = 0
421 partially_covered_sum = 0
422 for line in line_coverage:
423 if line.lineno not in line_numbers:
424 continue
425 if line.covered_status == LineCoverage.COVERED:
426 covered_count += 1
427 elif line.covered_status == LineCoverage.NOT_COVERED:
428 not_covered_count += 1
429 elif line.covered_status == LineCoverage.PARTIALLY_COVERED:
430 partially_covered_count += 1
431 partially_covered_sum += line.fractional_line_coverage
432
433 total_covered = covered_count + partially_covered_sum
434 total_lines = covered_count + partially_covered_count + not_covered_count
435 return (total_covered, total_lines)
436
437 @staticmethod
438 def NeedsCoverage(file_path):
439 """Checks to see if the file needs to be analyzed for code coverage.
440
441 Args:
442 file_path: A string representing path to the file.
443
444 Returns:
445 True for Java files that exist, False for all others.
446 """
447 return (os.path.splitext(file_path)[1] == '.java'
448 and os.path.exists(file_path))
449
450 @staticmethod
451 def GetPackageNameFromFile(file_path):
452 """Gets the full package name including the file name for a given file path.
453
454 Args:
455 file_path: String representing the path to the Java source file.
456
457 Returns:
458 string representing the full package name
459 ex. org.chromium.chrome.Activity.java or None if there is no package
460 statement in the file.
461 """
462 with open(file_path) as f:
463 file_content = f.read()
464 package_match = re.search(_EmmaCoverageStats.RE_PACKAGE, file_content)
465 if package_match:
466 package = package_match.group(1)
467 file_name = os.path.basename(file_path)
468 return package + '.' + file_name
469 else:
470 return None
471
472
473 def GenerateCoverageReport(line_coverage_file, out_file_path, coverage_dir):
474 """Generates a coverage report for a given set of lines.
475
476 Writes the results of the coverage analysis to the file specified by
477 |out_file_path|.
478
479 Args:
480 line_coverage_file: A dict mapping file names to lists of line numbers:
481 ex. {file1: [1, 2, 3], ...} means that we should compute coverage
482 information on lines 1 - 3 for file1.
483 out_file_path: A string representing the file path to write the status
484 JSON file.
485 coverage_dir: A string representing the file path where the EMMA
486 HTML coverage files are located (i.e. folder where index.html is located).
487
488 Raises:
489 IOError: A non existent |line_coverage_file| was supplied.
490 """
491 if not os.path.exists(line_coverage_file):
492 raise IOError('The line coverage file: %s does not exist.')
493
494 with open(line_coverage_file) as f:
495 files_for_coverage = json.load(f)
496 files_for_coverage = {f: lines for f, lines in files_for_coverage.iteritems()
497 if _EmmaCoverageStats.NeedsCoverage(f)}
498 if not files_for_coverage:
499 logging.info('No Java files requiring coverage stats were included in %s.',
500 line_coverage_file)
501 sys.exit(0)
502
503 code_coverage = _EmmaCoverageStats(coverage_dir, files_for_coverage.keys())
504 coverage_results = code_coverage.GetCoverageDictForFiles(files_for_coverage)
505 # Log summary and save stats to file.
506 covered = coverage_results['patch']['incremental']['covered']
507 total = coverage_results['patch']['incremental']['total']
508 percent = float(covered) / float(total) * 100 if total else 0
509 logging.info('Covered %s out of %s lines (%.2f%%).',
510 covered, total, round(percent, 2))
511 with open(out_file_path, 'w+') as out_status_file:
512 json.dump(coverage_results, out_status_file)
513
514
515 def main():
516 argparser = argparse.ArgumentParser()
517 argparser.add_argument('--out', required=True, type=str,
518 help='Report output file path.')
519 argparser.add_argument('--emma-dir', required=True, type=str,
520 help='EMMA HTML report directory.')
521 argparser.add_argument('--lines-for-coverage', required=True, type=str,
522 help='File containing a JSON object. Should contain a '
523 'dict mapping file names to lists of line numbers of '
524 'code for which coverage information is desired.')
525 argparser.add_argument('-v', '--verbose', action='count',
526 help='Print verbose log information.')
527 args = argparser.parse_args()
528 run_tests_helper.SetLogLevel(args.verbose)
529 GenerateCoverageReport(args.lines_for_coverage, args.out, args.emma_dir)
530
531
532 if __name__ == '__main__':
533 sys.exit(main())
OLDNEW
« no previous file with comments | « no previous file | build/android/coverage_test.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698