Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 1 | # 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 | |
| 7 | import argparse |
| 8 | from collections import defaultdict |
| 9 | import functools |
| 10 | import jinja2 |
| 11 | import json |
| 12 | import logging |
| 13 | import os |
| 14 | import re |
| 15 | import shutil |
| 16 | import subprocess |
| 17 | import sys |
| 18 | |
| 19 | # The default name of the html coverage report for a directory. |
| 20 | DIRECTORY_COVERAGE_HTML_REPORT_NAME = os.extsep.join(['report', 'html']) |
| 21 | |
| 22 | # Name of the html index files for different views. |
| 23 | COMPONENT_VIEW_INDEX_FILE = os.extsep.join(['component_view_index', 'html']) |
| 24 | DIRECTORY_VIEW_INDEX_FILE = os.extsep.join(['directory_view_index', 'html']) |
| 25 | FILE_VIEW_INDEX_FILE = os.extsep.join(['file_view_index', 'html']) |
| 26 | INDEX_HTML_FILE = os.extsep.join(['index', 'html']) |
| 27 | |
| 28 | |
| 29 | class 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 | |
| 67 | class 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 | |
| 231 | class 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 Moroz | 70263d6 | 2018-08-21 17:19:31 | [diff] [blame] | 240 | component_mappings={}, |
| 241 | path_equivalence=None): |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 242 | """Initializes CoverageReportPostProcessor object.""" |
| 243 | # Caller provided parameters. |
| 244 | self.output_dir = output_dir |
Max Moroz | 5e2058f | 2018-08-23 15:02:03 | [diff] [blame] | 245 | 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 Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 248 | 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 Moroz | 70263d6 | 2018-08-21 17:19:31 | [diff] [blame] | 273 | 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 Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 286 | 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 Moroz | 70263d6 | 2018-08-21 17:19:31 | [diff] [blame] | 305 | 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 Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 311 | 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 Moroz | 5e2058f | 2018-08-23 15:02:03 | [diff] [blame] | 323 | if os.path.normpath(parent_dir) == os.path.normpath(self.src_root_dir): |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 324 | 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 Moroz | 70263d6 | 2018-08-21 17:19:31 | [diff] [blame] | 413 | assert os.path.isdir( |
| 414 | self._MapToLocal(dir_path)), '"%s" is not a directory.' % dir_path |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 415 | 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 Moroz | 70263d6 | 2018-08-21 17:19:31 | [diff] [blame] | 425 | assert os.path.isfile( |
| 426 | self._MapToLocal(file_path)), '"%s" is not a file.' % file_path |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 427 | 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 Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 445 | 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 Moroz | 5e2058f | 2018-08-23 15:02:03 | [diff] [blame] | 462 | assert file_path.startswith(self.src_root_dir), ( |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 463 | '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 Moroz | 70263d6 | 2018-08-21 17:19:31 | [diff] [blame] | 499 | for entry_name in os.listdir(self._MapToLocal(dir_path)): |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 500 | 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 | |
| 602 | def 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 | |
| 609 | def 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 | |
| 615 | def GetCoverageReportRootDirPath(output_dir): |
| 616 | """The root directory that contains all generated coverage html reports.""" |
| 617 | return os.path.join(output_dir, GetHostPlatform()) |
| 618 | |
| 619 | |
| 620 | def 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 | |
| 626 | def 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 | |
| 632 | def GetHtmlIndexPath(output_dir): |
| 633 | """Path to the main HTML index file.""" |
| 634 | return os.path.join(GetCoverageReportRootDirPath(output_dir), INDEX_HTML_FILE) |
| 635 | |
| 636 | |
| 637 | def GetFullPath(path): |
| 638 | """Return full absolute path.""" |
Max Moroz | 00b9411 | 2018-09-05 04:08:05 | [diff] [blame] | 639 | return os.path.abspath(os.path.expandvars(os.path.expanduser(path))) |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 640 | |
| 641 | |
| 642 | def 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 | |
| 656 | def 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 | |
| 669 | def 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 | |
| 720 | def 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 | |
| 730 | def 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 | |
| 746 | def _CmdSharedLibraries(args): |
| 747 | """Handles 'shared_libs' command.""" |
| 748 | if not args.object: |
| 749 | logging.error('No binaries are specified.') |
| 750 | return 1 |
| 751 | |
Max Moroz | a19fd49 | 2018-10-22 17:07:11 | [diff] [blame^] | 752 | library_paths = GetSharedLibraries(args.object, args.build_dir) |
| 753 | if not library_paths: |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 754 | return 0 |
| 755 | |
| 756 | # Print output in the format that can be passed to llvm-cov tool. |
Max Moroz | a19fd49 | 2018-10-22 17:07:11 | [diff] [blame^] | 757 | output = ' '.join( |
| 758 | '-object=%s' % os.path.normpath(path) for path in library_paths) |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 759 | print(output) |
| 760 | return 0 |
| 761 | |
| 762 | |
| 763 | def _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 Moroz | 70263d6 | 2018-08-21 17:19:31 | [diff] [blame] | 773 | no_file_view=False, |
| 774 | path_equivalence=args.path_equivalence) |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 775 | processor.PrepareHtmlReport() |
| 776 | |
| 777 | |
| 778 | def 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 Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 791 | shared_libs_parser.add_argument( |
Max Moroz | 70263d6 | 2018-08-21 17:19:31 | [diff] [blame] | 792 | '-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 Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 798 | |
| 799 | post_processing_parser = subparsers.add_parser( |
| 800 | 'post_process', help='Post process a report.') |
| 801 | post_processing_parser.add_argument( |
Max Moroz | 70263d6 | 2018-08-21 17:19:31 | [diff] [blame] | 802 | '-output-dir', help='Path to the report dir.', required=True) |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 803 | post_processing_parser.add_argument( |
Max Moroz | 70263d6 | 2018-08-21 17:19:31 | [diff] [blame] | 804 | '-src-root-dir', help='Path to the src root dir.', required=True) |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 805 | post_processing_parser.add_argument( |
Max Moroz | 70263d6 | 2018-08-21 17:19:31 | [diff] [blame] | 806 | '-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 Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 811 | |
| 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 Moroz | 70263d6 | 2018-08-21 17:19:31 | [diff] [blame] | 819 | else: |
| 820 | parser.print_help(sys.stderr) |
Max Moroz | 1de68d7 | 2018-08-21 13:38:18 | [diff] [blame] | 821 | |
| 822 | |
| 823 | if __name__ == '__main__': |
| 824 | sys.exit(Main()) |