blob: fab56ceb3ba10e1fa4032babc435c0606a880941 [file] [log] [blame]
Max Moroz1de68d72018-08-21 13:38:181# Copyright 2018 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4#
5# The script intentionally does not have a shebang, as it is Py2/Py3 compatible.
6
7import argparse
8from collections import defaultdict
9import functools
10import jinja2
11import json
12import logging
13import os
14import re
15import shutil
16import subprocess
17import sys
18
19# The default name of the html coverage report for a directory.
20DIRECTORY_COVERAGE_HTML_REPORT_NAME = os.extsep.join(['report', 'html'])
21
22# Name of the html index files for different views.
23COMPONENT_VIEW_INDEX_FILE = os.extsep.join(['component_view_index', 'html'])
24DIRECTORY_VIEW_INDEX_FILE = os.extsep.join(['directory_view_index', 'html'])
25FILE_VIEW_INDEX_FILE = os.extsep.join(['file_view_index', 'html'])
26INDEX_HTML_FILE = os.extsep.join(['index', 'html'])
27
28
29class CoverageSummary(object):
30 """Encapsulates coverage summary representation."""
31
32 def __init__(self,
33 regions_total=0,
34 regions_covered=0,
35 functions_total=0,
36 functions_covered=0,
37 lines_total=0,
38 lines_covered=0):
39 """Initializes CoverageSummary object."""
40 self._summary = {
41 'regions': {
42 'total': regions_total,
43 'covered': regions_covered
44 },
45 'functions': {
46 'total': functions_total,
47 'covered': functions_covered
48 },
49 'lines': {
50 'total': lines_total,
51 'covered': lines_covered
52 }
53 }
54
55 def Get(self):
56 """Returns summary as a dictionary."""
57 return self._summary
58
59 def AddSummary(self, other_summary):
60 """Adds another summary to this one element-wise."""
61 for feature in self._summary:
62 self._summary[feature]['total'] += other_summary.Get()[feature]['total']
63 self._summary[feature]['covered'] += other_summary.Get()[feature][
64 'covered']
65
66
67class CoverageReportHtmlGenerator(object):
68 """Encapsulates coverage html report generation.
69
70 The generated html has a table that contains links to other coverage reports.
71 """
72
73 def __init__(self, output_dir, output_path, table_entry_type):
74 """Initializes _CoverageReportHtmlGenerator object.
75
76 Args:
77 output_dir: Path to the dir for writing coverage report to.
78 output_path: Path to the html report that will be generated.
79 table_entry_type: Type of the table entries to be displayed in the table
80 header. For example: 'Path', 'Component'.
81 """
82 css_file_name = os.extsep.join(['style', 'css'])
83 css_absolute_path = os.path.join(output_dir, css_file_name)
84 assert os.path.exists(css_absolute_path), (
85 'css file doesn\'t exit. Please make sure "llvm-cov show -format=html" '
86 'is called first, and the css file is generated at: "%s".' %
87 css_absolute_path)
88
89 self._css_absolute_path = css_absolute_path
90 self._output_dir = output_dir
91 self._output_path = output_path
92 self._table_entry_type = table_entry_type
93
94 self._table_entries = []
95 self._total_entry = {}
96
97 source_dir = os.path.dirname(os.path.realpath(__file__))
98 template_dir = os.path.join(source_dir, 'html_templates')
99
100 jinja_env = jinja2.Environment(
101 loader=jinja2.FileSystemLoader(template_dir), trim_blocks=True)
102 self._header_template = jinja_env.get_template('header.html')
103 self._table_template = jinja_env.get_template('table.html')
104 self._footer_template = jinja_env.get_template('footer.html')
105
106 self._style_overrides = open(
107 os.path.join(source_dir, 'static', 'css', 'style.css')).read()
108
109 def AddLinkToAnotherReport(self, html_report_path, name, summary):
110 """Adds a link to another html report in this report.
111
112 The link to be added is assumed to be an entry in this directory.
113 """
114 # Use relative paths instead of absolute paths to make the generated reports
115 # portable.
116 html_report_relative_path = GetRelativePathToDirectoryOfFile(
117 html_report_path, self._output_path)
118
119 table_entry = self._CreateTableEntryFromCoverageSummary(
120 summary, html_report_relative_path, name,
121 os.path.basename(html_report_path) ==
122 DIRECTORY_COVERAGE_HTML_REPORT_NAME)
123 self._table_entries.append(table_entry)
124
125 def CreateTotalsEntry(self, summary):
126 """Creates an entry corresponds to the 'Totals' row in the html report."""
127 self._total_entry = self._CreateTableEntryFromCoverageSummary(summary)
128
129 def _CreateTableEntryFromCoverageSummary(self,
130 summary,
131 href=None,
132 name=None,
133 is_dir=None):
134 """Creates an entry to display in the html report."""
135 assert (href is None and name is None and is_dir is None) or (
136 href is not None and name is not None and is_dir is not None), (
137 'The only scenario when href or name or is_dir can be None is when '
138 'creating an entry for the Totals row, and in that case, all three '
139 'attributes must be None.')
140
141 entry = {}
142 if href is not None:
143 entry['href'] = href
144 if name is not None:
145 entry['name'] = name
146 if is_dir is not None:
147 entry['is_dir'] = is_dir
148
149 summary_dict = summary.Get()
150 for feature in summary_dict:
151 if summary_dict[feature]['total'] == 0:
152 percentage = 0.0
153 else:
154 percentage = float(summary_dict[feature]
155 ['covered']) / summary_dict[feature]['total'] * 100
156
157 color_class = self._GetColorClass(percentage)
158 entry[feature] = {
159 'total': summary_dict[feature]['total'],
160 'covered': summary_dict[feature]['covered'],
161 'percentage': '{:6.2f}'.format(percentage),
162 'color_class': color_class
163 }
164
165 return entry
166
167 def _GetColorClass(self, percentage):
168 """Returns the css color class based on coverage percentage."""
169 if percentage >= 0 and percentage < 80:
170 return 'red'
171 if percentage >= 80 and percentage < 100:
172 return 'yellow'
173 if percentage == 100:
174 return 'green'
175
176 assert False, 'Invalid coverage percentage: "%d".' % percentage
177
178 def WriteHtmlCoverageReport(self, no_component_view, no_file_view):
179 """Writes html coverage report.
180
181 In the report, sub-directories are displayed before files and within each
182 category, entries are sorted alphabetically.
183 """
184
185 def EntryCmp(left, right):
186 """Compare function for table entries."""
187 if left['is_dir'] != right['is_dir']:
188 return -1 if left['is_dir'] == True else 1
189
190 return -1 if left['name'] < right['name'] else 1
191
192 self._table_entries = sorted(
193 self._table_entries, key=functools.cmp_to_key(EntryCmp))
194
195 css_path = os.path.join(self._output_dir, os.extsep.join(['style', 'css']))
196
197 directory_view_path = GetDirectoryViewPath(self._output_dir)
198 directory_view_href = GetRelativePathToDirectoryOfFile(
199 directory_view_path, self._output_path)
200
201 component_view_href = None
202 if not no_component_view:
203 component_view_path = GetComponentViewPath(self._output_dir)
204 component_view_href = GetRelativePathToDirectoryOfFile(
205 component_view_path, self._output_path)
206
207 # File view is optional in the report.
208 file_view_href = None
209 if not no_file_view:
210 file_view_path = GetFileViewPath(self._output_dir)
211 file_view_href = GetRelativePathToDirectoryOfFile(file_view_path,
212 self._output_path)
213
214 html_header = self._header_template.render(
215 css_path=GetRelativePathToDirectoryOfFile(css_path, self._output_path),
216 directory_view_href=directory_view_href,
217 component_view_href=component_view_href,
218 file_view_href=file_view_href,
219 style_overrides=self._style_overrides)
220
221 html_table = self._table_template.render(
222 entries=self._table_entries,
223 total_entry=self._total_entry,
224 table_entry_type=self._table_entry_type)
225 html_footer = self._footer_template.render()
226
227 with open(self._output_path, 'w') as html_file:
228 html_file.write(html_header + html_table + html_footer)
229
230
231class CoverageReportPostProcessor(object):
232 """Post processing of code coverage reports produced by llvm-cov."""
233
234 def __init__(self,
235 output_dir,
236 src_root_dir,
237 summary_data,
238 no_component_view,
239 no_file_view,
Max Moroz70263d62018-08-21 17:19:31240 component_mappings={},
241 path_equivalence=None):
Max Moroz1de68d72018-08-21 13:38:18242 """Initializes CoverageReportPostProcessor object."""
243 # Caller provided parameters.
244 self.output_dir = output_dir
Max Moroz5e2058f2018-08-23 15:02:03245 self.src_root_dir = os.path.normpath(GetFullPath(src_root_dir))
246 if not self.src_root_dir.endswith(os.sep):
247 self.src_root_dir += os.sep
Max Moroz1de68d72018-08-21 13:38:18248 self.summary_data = json.loads(summary_data)
249 assert len(self.summary_data['data']) == 1
250 self.no_component_view = no_component_view
251 self.no_file_view = no_file_view
252
253 # Mapping from components to directories
254 self.component_to_directories = None
255 if component_mappings:
256 self._ExtractComponentToDirectoriesMapping(component_mappings)
257
258 # The root directory that contains all generated coverage html reports.
259 self.report_root_dir = GetCoverageReportRootDirPath(self.output_dir)
260
261 # Path to the HTML file for the component view.
262 self.component_view_path = GetComponentViewPath(self.output_dir)
263
264 # Path to the HTML file for the directory view.
265 self.directory_view_path = GetDirectoryViewPath(self.output_dir)
266
267 # Path to the HTML file for the file view.
268 self.file_view_path = GetFileViewPath(self.output_dir)
269
270 # Path to the main HTML index file.
271 self.html_index_path = GetHtmlIndexPath(self.output_dir)
272
Max Moroz70263d62018-08-21 17:19:31273 self.path_map = None
274 if path_equivalence:
275
276 def _PreparePath(path):
277 path = os.path.normpath(path)
278 if not path.endswith(os.sep):
279 # A normalized path does not end with '/', unless it is a root dir.
280 path += os.sep
281 return path
282
283 self.path_map = [_PreparePath(p) for p in path_equivalence.split(',')]
284 assert len(self.path_map) == 2, 'Path equivalence argument is incorrect.'
285
Max Moroz1de68d72018-08-21 13:38:18286 def _ExtractComponentToDirectoriesMapping(self, component_mappings):
287 """Initializes a mapping from components to directories."""
288 directory_to_component = component_mappings['dir-to-component']
289
290 self.component_to_directories = defaultdict(list)
291 for directory in sorted(directory_to_component):
292 component = directory_to_component[directory]
293
294 # Check if we already added the parent directory of this directory. If
295 # yes,skip this sub-directory to avoid double-counting.
296 found_parent_directory = False
297 for component_directory in self.component_to_directories[component]:
298 if directory.startswith(component_directory + '/'):
299 found_parent_directory = True
300 break
301
302 if not found_parent_directory:
303 self.component_to_directories[component].append(directory)
304
Max Moroz70263d62018-08-21 17:19:31305 def _MapToLocal(self, path):
306 """Maps a path from the coverage data to a local path."""
307 if not self.path_map:
308 return path
309 return path.replace(self.path_map[0], self.path_map[1], 1)
310
Max Moroz1de68d72018-08-21 13:38:18311 def CalculatePerDirectoryCoverageSummary(self, per_file_coverage_summary):
312 """Calculates per directory coverage summary."""
313 logging.debug('Calculating per-directory coverage summary.')
314 per_directory_coverage_summary = defaultdict(lambda: CoverageSummary())
315
316 for file_path in per_file_coverage_summary:
317 summary = per_file_coverage_summary[file_path]
318 parent_dir = os.path.dirname(file_path)
319
320 while True:
321 per_directory_coverage_summary[parent_dir].AddSummary(summary)
322
Max Moroz5e2058f2018-08-23 15:02:03323 if os.path.normpath(parent_dir) == os.path.normpath(self.src_root_dir):
Max Moroz1de68d72018-08-21 13:38:18324 break
325 parent_dir = os.path.dirname(parent_dir)
326
327 logging.debug('Finished calculating per-directory coverage summary.')
328 return per_directory_coverage_summary
329
330 def CalculatePerComponentCoverageSummary(self,
331 per_directory_coverage_summary):
332 """Calculates per component coverage summary."""
333 logging.debug('Calculating per-component coverage summary.')
334 per_component_coverage_summary = defaultdict(lambda: CoverageSummary())
335
336 for component in self.component_to_directories:
337 for directory in self.component_to_directories[component]:
338 absolute_directory_path = GetFullPath(directory)
339 if absolute_directory_path in per_directory_coverage_summary:
340 per_component_coverage_summary[component].AddSummary(
341 per_directory_coverage_summary[absolute_directory_path])
342
343 logging.debug('Finished calculating per-component coverage summary.')
344 return per_component_coverage_summary
345
346 def GeneratePerComponentCoverageInHtml(self, per_component_coverage_summary,
347 per_directory_coverage_summary):
348 """Generates per-component coverage reports in html."""
349 logging.debug('Writing per-component coverage html reports.')
350 for component in per_component_coverage_summary:
351 self.GenerateCoverageInHtmlForComponent(component,
352 per_component_coverage_summary,
353 per_directory_coverage_summary)
354 logging.debug('Finished writing per-component coverage html reports.')
355
356 def GenerateComponentViewHtmlIndexFile(self, per_component_coverage_summary):
357 """Generates the html index file for component view."""
358 component_view_index_file_path = self.component_view_path
359 logging.debug('Generating component view html index file as: "%s".',
360 component_view_index_file_path)
361 html_generator = CoverageReportHtmlGenerator(
362 self.output_dir, component_view_index_file_path, 'Component')
363 for component in per_component_coverage_summary:
364 html_generator.AddLinkToAnotherReport(
365 self.GetCoverageHtmlReportPathForComponent(component), component,
366 per_component_coverage_summary[component])
367
368 # Do not create a totals row for the component view as the value is
369 # incorrect due to failure to account for UNKNOWN component and some paths
370 # belonging to multiple components.
371 html_generator.WriteHtmlCoverageReport(self.no_component_view,
372 self.no_file_view)
373 logging.debug('Finished generating component view html index file.')
374
375 def GenerateCoverageInHtmlForComponent(self, component_name,
376 per_component_coverage_summary,
377 per_directory_coverage_summary):
378 """Generates coverage html report for a component."""
379 component_html_report_path = self.GetCoverageHtmlReportPathForComponent(
380 component_name)
381 component_html_report_dir = os.path.dirname(component_html_report_path)
382 if not os.path.exists(component_html_report_dir):
383 os.makedirs(component_html_report_dir)
384
385 html_generator = CoverageReportHtmlGenerator(
386 self.output_dir, component_html_report_path, 'Path')
387
388 for dir_path in self.component_to_directories[component_name]:
389 dir_absolute_path = GetFullPath(dir_path)
390 if dir_absolute_path not in per_directory_coverage_summary:
391 # Any directory without an exercised file shouldn't be included into
392 # the report.
393 continue
394
395 html_generator.AddLinkToAnotherReport(
396 self.GetCoverageHtmlReportPathForDirectory(dir_path),
397 os.path.relpath(dir_path, self.src_root_dir),
398 per_directory_coverage_summary[dir_absolute_path])
399
400 html_generator.CreateTotalsEntry(
401 per_component_coverage_summary[component_name])
402 html_generator.WriteHtmlCoverageReport(self.no_component_view,
403 self.no_file_view)
404
405 def GetCoverageHtmlReportPathForComponent(self, component_name):
406 """Given a component, returns the corresponding html report path."""
407 component_file_name = component_name.lower().replace('>', '-')
408 html_report_name = os.extsep.join([component_file_name, 'html'])
409 return os.path.join(self.report_root_dir, 'components', html_report_name)
410
411 def GetCoverageHtmlReportPathForDirectory(self, dir_path):
412 """Given a directory path, returns the corresponding html report path."""
Max Moroz70263d62018-08-21 17:19:31413 assert os.path.isdir(
414 self._MapToLocal(dir_path)), '"%s" is not a directory.' % dir_path
Max Moroz1de68d72018-08-21 13:38:18415 html_report_path = os.path.join(
416 GetFullPath(dir_path), DIRECTORY_COVERAGE_HTML_REPORT_NAME)
417
418 # '+' is used instead of os.path.join because both of them are absolute
419 # paths and os.path.join ignores the first path.
420 # TODO(crbug.com/809150): Think of a generic cross platform fix (Windows).
421 return self.report_root_dir + html_report_path
422
423 def GetCoverageHtmlReportPathForFile(self, file_path):
424 """Given a file path, returns the corresponding html report path."""
Max Moroz70263d62018-08-21 17:19:31425 assert os.path.isfile(
426 self._MapToLocal(file_path)), '"%s" is not a file.' % file_path
Max Moroz1de68d72018-08-21 13:38:18427 html_report_path = os.extsep.join([GetFullPath(file_path), 'html'])
428
429 # '+' is used instead of os.path.join because both of them are absolute
430 # paths and os.path.join ignores the first path.
431 # TODO(crbug.com/809150): Think of a generic cross platform fix (Windows).
432 return self.report_root_dir + html_report_path
433
434 def GenerateFileViewHtmlIndexFile(self, per_file_coverage_summary,
435 file_view_index_file_path):
436 """Generates html index file for file view."""
437 logging.debug('Generating file view html index file as: "%s".',
438 file_view_index_file_path)
439 html_generator = CoverageReportHtmlGenerator(
440 self.output_dir, file_view_index_file_path, 'Path')
441 totals_coverage_summary = CoverageSummary()
442
443 for file_path in per_file_coverage_summary:
444 totals_coverage_summary.AddSummary(per_file_coverage_summary[file_path])
Max Moroz1de68d72018-08-21 13:38:18445 html_generator.AddLinkToAnotherReport(
446 self.GetCoverageHtmlReportPathForFile(file_path),
447 os.path.relpath(file_path, self.src_root_dir),
448 per_file_coverage_summary[file_path])
449
450 html_generator.CreateTotalsEntry(totals_coverage_summary)
451 html_generator.WriteHtmlCoverageReport(self.no_component_view,
452 self.no_file_view)
453 logging.debug('Finished generating file view html index file.')
454
455 def GeneratePerFileCoverageSummary(self):
456 """Generate per file coverage summary using coverage data in JSON format."""
457 files_coverage_data = self.summary_data['data'][0]['files']
458
459 per_file_coverage_summary = {}
460 for file_coverage_data in files_coverage_data:
461 file_path = file_coverage_data['filename']
Max Moroz5e2058f2018-08-23 15:02:03462 assert file_path.startswith(self.src_root_dir), (
Max Moroz1de68d72018-08-21 13:38:18463 'File path "%s" in coverage summary is outside source checkout.' %
464 file_path)
465
466 summary = file_coverage_data['summary']
467 if summary['lines']['count'] == 0:
468 continue
469
470 per_file_coverage_summary[file_path] = CoverageSummary(
471 regions_total=summary['regions']['count'],
472 regions_covered=summary['regions']['covered'],
473 functions_total=summary['functions']['count'],
474 functions_covered=summary['functions']['covered'],
475 lines_total=summary['lines']['count'],
476 lines_covered=summary['lines']['covered'])
477
478 logging.debug('Finished generating per-file code coverage summary.')
479 return per_file_coverage_summary
480
481 def GeneratePerDirectoryCoverageInHtml(self, per_directory_coverage_summary,
482 per_file_coverage_summary):
483 """Generates per directory coverage breakdown in html."""
484 logging.debug('Writing per-directory coverage html reports.')
485 for dir_path in per_directory_coverage_summary:
486 self.GenerateCoverageInHtmlForDirectory(
487 dir_path, per_directory_coverage_summary, per_file_coverage_summary)
488
489 logging.debug('Finished writing per-directory coverage html reports.')
490
491 def GenerateCoverageInHtmlForDirectory(self, dir_path,
492 per_directory_coverage_summary,
493 per_file_coverage_summary):
494 """Generates coverage html report for a single directory."""
495 html_generator = CoverageReportHtmlGenerator(
496 self.output_dir, self.GetCoverageHtmlReportPathForDirectory(dir_path),
497 'Path')
498
Max Moroz70263d62018-08-21 17:19:31499 for entry_name in os.listdir(self._MapToLocal(dir_path)):
Max Moroz1de68d72018-08-21 13:38:18500 entry_path = os.path.normpath(os.path.join(dir_path, entry_name))
501
502 if entry_path in per_file_coverage_summary:
503 entry_html_report_path = self.GetCoverageHtmlReportPathForFile(
504 entry_path)
505 entry_coverage_summary = per_file_coverage_summary[entry_path]
506 elif entry_path in per_directory_coverage_summary:
507 entry_html_report_path = self.GetCoverageHtmlReportPathForDirectory(
508 entry_path)
509 entry_coverage_summary = per_directory_coverage_summary[entry_path]
510 else:
511 # Any file without executable lines shouldn't be included into the
512 # report. For example, OWNER and README.md files.
513 continue
514
515 html_generator.AddLinkToAnotherReport(entry_html_report_path,
516 os.path.basename(entry_path),
517 entry_coverage_summary)
518
519 html_generator.CreateTotalsEntry(per_directory_coverage_summary[dir_path])
520 html_generator.WriteHtmlCoverageReport(self.no_component_view,
521 self.no_file_view)
522
523 def GenerateDirectoryViewHtmlIndexFile(self):
524 """Generates the html index file for directory view.
525
526 Note that the index file is already generated under src_root_dir, so this
527 file simply redirects to it, and the reason of this extra layer is for
528 structural consistency with other views.
529 """
530 directory_view_index_file_path = self.directory_view_path
531 logging.debug('Generating directory view html index file as: "%s".',
532 directory_view_index_file_path)
533 src_root_html_report_path = self.GetCoverageHtmlReportPathForDirectory(
534 self.src_root_dir)
535 WriteRedirectHtmlFile(directory_view_index_file_path,
536 src_root_html_report_path)
537 logging.debug('Finished generating directory view html index file.')
538
539 def RenameDefaultCoverageDirectory(self):
540 """Rename the default coverage directory into platform specific name."""
541 # llvm-cov creates "coverage" subdir in the output dir. We would like to use
542 # the platform name instead, as it simplifies the report dir structure when
543 # the same report is generated for different platforms.
544 default_report_subdir_path = os.path.join(self.output_dir, 'coverage')
545 if not os.path.exists(default_report_subdir_path):
546 logging.error('Default coverage report dir does not exist: %s.',
547 default_report_subdir_path)
548
549 if not os.path.exists(self.report_root_dir):
550 os.mkdir(self.report_root_dir)
551
552 MergeTwoDirectories(default_report_subdir_path, self.report_root_dir)
553
554 def OverwriteHtmlReportsIndexFile(self):
555 """Overwrites the root index file to redirect to the default view."""
556 html_index_file_path = self.html_index_path
557 directory_view_index_file_path = self.directory_view_path
558 WriteRedirectHtmlFile(html_index_file_path, directory_view_index_file_path)
559
560 def CleanUpOutputDir(self):
561 """Perform a cleanup of the output dir."""
562 # Remove the default index.html file produced by llvm-cov.
563 index_path = os.path.join(self.output_dir, INDEX_HTML_FILE)
564 if os.path.exists(index_path):
565 os.remove(index_path)
566
567 def PrepareHtmlReport(self):
568 self.RenameDefaultCoverageDirectory()
569
570 per_file_coverage_summary = self.GeneratePerFileCoverageSummary()
571
572 if not self.no_file_view:
573 self.GenerateFileViewHtmlIndexFile(per_file_coverage_summary,
574 self.file_view_path)
575
576 per_directory_coverage_summary = self.CalculatePerDirectoryCoverageSummary(
577 per_file_coverage_summary)
578
579 self.GeneratePerDirectoryCoverageInHtml(per_directory_coverage_summary,
580 per_file_coverage_summary)
581
582 self.GenerateDirectoryViewHtmlIndexFile()
583
584 if not self.no_component_view:
585 per_component_coverage_summary = (
586 self.CalculatePerComponentCoverageSummary(
587 per_directory_coverage_summary))
588 self.GeneratePerComponentCoverageInHtml(per_component_coverage_summary,
589 per_directory_coverage_summary)
590 self.GenerateComponentViewHtmlIndexFile(per_component_coverage_summary)
591
592 # The default index file is generated only for the list of source files,
593 # needs to overwrite it to display per directory coverage view by default.
594 self.OverwriteHtmlReportsIndexFile()
595 self.CleanUpOutputDir()
596
597 html_index_file_path = 'file://' + GetFullPath(self.html_index_path)
598 logging.info('Index file for html report is generated as: "%s".',
599 html_index_file_path)
600
601
602def ConfigureLogging(verbose=False, log_file=None):
603 """Configures logging settings for later use."""
604 log_level = logging.DEBUG if verbose else logging.INFO
605 log_format = '[%(asctime)s %(levelname)s] %(message)s'
606 logging.basicConfig(filename=log_file, level=log_level, format=log_format)
607
608
609def GetComponentViewPath(output_dir):
610 """Path to the HTML file for the component view."""
611 return os.path.join(
612 GetCoverageReportRootDirPath(output_dir), COMPONENT_VIEW_INDEX_FILE)
613
614
615def GetCoverageReportRootDirPath(output_dir):
616 """The root directory that contains all generated coverage html reports."""
617 return os.path.join(output_dir, GetHostPlatform())
618
619
620def GetDirectoryViewPath(output_dir):
621 """Path to the HTML file for the directory view."""
622 return os.path.join(
623 GetCoverageReportRootDirPath(output_dir), DIRECTORY_VIEW_INDEX_FILE)
624
625
626def GetFileViewPath(output_dir):
627 """Path to the HTML file for the file view."""
628 return os.path.join(
629 GetCoverageReportRootDirPath(output_dir), FILE_VIEW_INDEX_FILE)
630
631
632def GetHtmlIndexPath(output_dir):
633 """Path to the main HTML index file."""
634 return os.path.join(GetCoverageReportRootDirPath(output_dir), INDEX_HTML_FILE)
635
636
637def GetFullPath(path):
638 """Return full absolute path."""
Max Moroz00b94112018-09-05 04:08:05639 return os.path.abspath(os.path.expandvars(os.path.expanduser(path)))
Max Moroz1de68d72018-08-21 13:38:18640
641
642def GetHostPlatform():
643 """Returns the host platform.
644
645 This is separate from the target platform/os that coverage is running for.
646 """
647 if sys.platform == 'win32' or sys.platform == 'cygwin':
648 return 'win'
649 if sys.platform.startswith('linux'):
650 return 'linux'
651 else:
652 assert sys.platform == 'darwin'
653 return 'mac'
654
655
656def GetRelativePathToDirectoryOfFile(target_path, base_path):
657 """Returns a target path relative to the directory of base_path.
658
659 This method requires base_path to be a file, otherwise, one should call
660 os.path.relpath directly.
661 """
662 assert os.path.dirname(base_path) != base_path, (
663 'Base path: "%s" is a directory, please call os.path.relpath directly.' %
664 base_path)
665 base_dir = os.path.dirname(base_path)
666 return os.path.relpath(target_path, base_dir)
667
668
669def GetSharedLibraries(binary_paths, build_dir):
670 """Returns list of shared libraries used by specified binaries."""
671 logging.info('Finding shared libraries for targets (if any).')
672 shared_libraries = []
673 cmd = []
674 shared_library_re = None
675
676 if sys.platform.startswith('linux'):
677 cmd.extend(['ldd'])
678 shared_library_re = re.compile(r'.*\.so[.0-9]*\s=>\s(.*' + build_dir +
679 r'.*\.so[.0-9]*)\s.*')
680 elif sys.platform.startswith('darwin'):
681 cmd.extend(['otool', '-L'])
682 shared_library_re = re.compile(r'\s+(@rpath/.*\.dylib)\s.*')
683 else:
684 assert False, 'Cannot detect shared libraries used by the given targets.'
685
686 assert shared_library_re is not None
687
688 cmd.extend(binary_paths)
689 output = subprocess.check_output(cmd).decode('utf-8', 'ignore')
690
691 for line in output.splitlines():
692 m = shared_library_re.match(line)
693 if not m:
694 continue
695
696 shared_library_path = m.group(1)
697 if sys.platform.startswith('darwin'):
698 # otool outputs "@rpath" macro instead of the dirname of the given binary.
699 shared_library_path = shared_library_path.replace('@rpath', build_dir)
700
701 if shared_library_path in shared_libraries:
702 continue
703
704 assert os.path.exists(shared_library_path), ('Shared library "%s" used by '
705 'the given target(s) does not '
706 'exist.' % shared_library_path)
707 with open(shared_library_path, 'rb') as f:
708 data = f.read()
709
710 # Do not add non-instrumented libraries. Otherwise, llvm-cov errors outs.
711 if b'__llvm_cov' in data:
712 shared_libraries.append(shared_library_path)
713
714 logging.debug('Found shared libraries (%d): %s.', len(shared_libraries),
715 shared_libraries)
716 logging.info('Finished finding shared libraries for targets.')
717 return shared_libraries
718
719
720def MergeTwoDirectories(src_dir_path, dst_dir_path):
721 """Merge src_dir_path directory into dst_path directory."""
722 for filename in os.listdir(src_dir_path):
723 dst_path = os.path.join(dst_dir_path, filename)
724 if os.path.exists(dst_path):
725 shutil.rmtree(dst_path)
726 os.rename(os.path.join(src_dir_path, filename), dst_path)
727 shutil.rmtree(src_dir_path)
728
729
730def WriteRedirectHtmlFile(from_html_path, to_html_path):
731 """Writes a html file that redirects to another html file."""
732 to_html_relative_path = GetRelativePathToDirectoryOfFile(
733 to_html_path, from_html_path)
734 content = ("""
735 <!DOCTYPE html>
736 <html>
737 <head>
738 <!-- HTML meta refresh URL redirection -->
739 <meta http-equiv="refresh" content="0; url=%s">
740 </head>
741 </html>""" % to_html_relative_path)
742 with open(from_html_path, 'w') as f:
743 f.write(content)
744
745
746def _CmdSharedLibraries(args):
747 """Handles 'shared_libs' command."""
748 if not args.object:
749 logging.error('No binaries are specified.')
750 return 1
751
Max Moroza19fd492018-10-22 17:07:11752 library_paths = GetSharedLibraries(args.object, args.build_dir)
753 if not library_paths:
Max Moroz1de68d72018-08-21 13:38:18754 return 0
755
756 # Print output in the format that can be passed to llvm-cov tool.
Max Moroza19fd492018-10-22 17:07:11757 output = ' '.join(
758 '-object=%s' % os.path.normpath(path) for path in library_paths)
Max Moroz1de68d72018-08-21 13:38:18759 print(output)
760 return 0
761
762
763def _CmdPostProcess(args):
764 """Handles 'post_process' command."""
765 with open(args.summary_file) as f:
766 summary_data = f.read()
767
768 processor = CoverageReportPostProcessor(
769 args.output_dir,
770 args.src_root_dir,
771 summary_data,
772 no_component_view=True,
Max Moroz70263d62018-08-21 17:19:31773 no_file_view=False,
774 path_equivalence=args.path_equivalence)
Max Moroz1de68d72018-08-21 13:38:18775 processor.PrepareHtmlReport()
776
777
778def Main():
779 parser = argparse.ArgumentParser(
780 'coverage_utils', description='Code coverage utils.')
781 parser.add_argument(
782 '-v',
783 '--verbose',
784 action='store_true',
785 help='Prints additional debug output.')
786
787 subparsers = parser.add_subparsers(dest='command')
788
789 shared_libs_parser = subparsers.add_parser(
790 'shared_libs', help='Detect shared libraries.')
Max Moroz1de68d72018-08-21 13:38:18791 shared_libs_parser.add_argument(
Max Moroz70263d62018-08-21 17:19:31792 '-build-dir', help='Path to the build dir.', required=True)
793 shared_libs_parser.add_argument(
794 '-object',
795 action='append',
796 help='Path to the binary using shared libs.',
797 required=True)
Max Moroz1de68d72018-08-21 13:38:18798
799 post_processing_parser = subparsers.add_parser(
800 'post_process', help='Post process a report.')
801 post_processing_parser.add_argument(
Max Moroz70263d62018-08-21 17:19:31802 '-output-dir', help='Path to the report dir.', required=True)
Max Moroz1de68d72018-08-21 13:38:18803 post_processing_parser.add_argument(
Max Moroz70263d62018-08-21 17:19:31804 '-src-root-dir', help='Path to the src root dir.', required=True)
Max Moroz1de68d72018-08-21 13:38:18805 post_processing_parser.add_argument(
Max Moroz70263d62018-08-21 17:19:31806 '-summary-file', help='Path to the summary file.', required=True)
807 post_processing_parser.add_argument(
808 '-path-equivalence',
809 help='Map the paths in the coverage data to local '
810 'source files path (=<from>,<to>)')
Max Moroz1de68d72018-08-21 13:38:18811
812 args = parser.parse_args()
813 ConfigureLogging(args.verbose)
814
815 if args.command == 'shared_libs':
816 return _CmdSharedLibraries(args)
817 elif args.command == 'post_process':
818 return _CmdPostProcess(args)
Max Moroz70263d62018-08-21 17:19:31819 else:
820 parser.print_help(sys.stderr)
Max Moroz1de68d72018-08-21 13:38:18821
822
823if __name__ == '__main__':
824 sys.exit(Main())