2 # Copyright (c) 2012 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.
6 """Crocodile - compute coverage numbers for Chrome coverage dashboard."""
17 class CrocError(Exception):
21 class CrocStatError(CrocError
):
22 """Error evaluating coverage stat."""
24 #------------------------------------------------------------------------------
27 class CoverageStats(dict):
28 """Coverage statistics."""
30 # Default dictionary values for this stat.
31 DEFAULTS
= { 'files_covered': 0,
32 'files_instrumented': 0,
33 'files_executable': 0,
35 'lines_instrumented': 0,
36 'lines_executable': 0 }
38 def Add(self
, coverage_stats
):
39 """Adds a contribution from another coverage stats dict.
42 coverage_stats: Statistics to add to this one.
44 for k
, v
in coverage_stats
.iteritems():
50 def AddDefaults(self
):
51 """Add some default stats which might be assumed present.
53 Do not clobber if already present. Adds resilience when evaling a
54 croc file which expects certain stats to exist."""
55 for k
, v
in self
.DEFAULTS
.iteritems():
59 #------------------------------------------------------------------------------
62 class CoveredFile(object):
63 """Information about a single covered file."""
65 def __init__(self
, filename
, **kwargs
):
69 filename: Full path to file, '/'-delimited.
70 kwargs: Keyword args are attributes for file.
72 self
.filename
= filename
73 self
.attrs
= dict(kwargs
)
75 # Move these to attrs?
76 self
.local_path
= None # Local path to file
77 self
.in_lcov
= False # Is file instrumented?
79 # No coverage data for file yet
80 self
.lines
= {} # line_no -> None=executable, 0=instrumented, 1=covered
81 self
.stats
= CoverageStats()
83 def UpdateCoverage(self
):
84 """Updates the coverage summary based on covered lines."""
86 for l
in self
.lines
.itervalues():
93 # Add stats that always exist
94 self
.stats
= CoverageStats(lines_executable
=exe
,
95 lines_instrumented
=instr
,
99 # Add conditional stats
101 self
.stats
['files_covered'] = 1
102 if instr
or self
.in_lcov
:
103 self
.stats
['files_instrumented'] = 1
105 #------------------------------------------------------------------------------
108 class CoveredDir(object):
109 """Information about a directory containing covered files."""
111 def __init__(self
, dirpath
):
115 dirpath: Full path of directory, '/'-delimited.
117 self
.dirpath
= dirpath
119 # List of covered files directly in this dir, indexed by filename (not
123 # List of subdirs, indexed by filename (not full path)
126 # Dict of CoverageStats objects summarizing all children, indexed by group
127 self
.stats_by_group
= {'all': CoverageStats()}
130 def GetTree(self
, indent
=''):
131 """Recursively gets stats for the directory and its children.
134 indent: indent prefix string.
137 The tree as a string.
141 # Compile all groupstats
143 for group
in sorted(self
.stats_by_group
):
144 s
= self
.stats_by_group
[group
]
145 if not s
.get('lines_executable'):
146 continue # Skip groups with no executable lines
147 groupstats
.append('%s:%d/%d/%d' % (
148 group
, s
.get('lines_covered', 0),
149 s
.get('lines_instrumented', 0),
150 s
.get('lines_executable', 0)))
152 outline
= '%s%-30s %s' % (indent
,
153 os
.path
.split(self
.dirpath
)[1] + '/',
154 ' '.join(groupstats
))
155 dest
.append(outline
.rstrip())
157 for d
in sorted(self
.subdirs
):
158 dest
.append(self
.subdirs
[d
].GetTree(indent
=indent
+ ' '))
160 return '\n'.join(dest
)
162 #------------------------------------------------------------------------------
165 class Coverage(object):
166 """Code coverage for a group of files."""
170 self
.files
= {} # Map filename --> CoverageFile
171 self
.root_dirs
= [] # (root, altname)
172 self
.rules
= [] # (regexp, dict of RHS attrs)
173 self
.tree
= CoveredDir('')
174 self
.print_stats
= [] # Dicts of args to PrintStat()
176 # Functions which need to be replaced for unit testing
177 self
.add_files_walk
= os
.walk
# Walk function for AddFiles()
178 self
.scan_file
= croc_scan
.ScanFile
# Source scanner for AddFiles()
180 def CleanupFilename(self
, filename
):
181 """Cleans up a filename.
184 filename: Input filename.
187 The cleaned up filename.
189 Changes all path separators to '/'.
190 Makes relative paths (those starting with '../' or './' absolute.
191 Replaces all instances of root dirs with alternate names.
193 # Change path separators
194 filename
= filename
.replace('\\', '/')
196 # Windows doesn't care about case sensitivity.
197 if platform
.system() in ['Windows', 'Microsoft']:
198 filename
= filename
.lower()
200 # If path is relative, make it absolute
201 # TODO: Perhaps we should default to relative instead, and only understand
202 # absolute to be files starting with '\', '/', or '[A-Za-z]:'?
203 if filename
.split('/')[0] in ('.', '..'):
204 filename
= os
.path
.abspath(filename
).replace('\\', '/')
206 # Replace alternate roots
207 for root
, alt_name
in self
.root_dirs
:
208 # Windows doesn't care about case sensitivity.
209 if platform
.system() in ['Windows', 'Microsoft']:
211 filename
= re
.sub('^' + re
.escape(root
) + '(?=(/|$))',
215 def ClassifyFile(self
, filename
):
216 """Applies rules to a filename, to see if we care about it.
219 filename: Input filename.
222 A dict of attributes for the file, accumulated from the right hand sides
223 of rules which fired.
228 for regexp
, rhs_dict
in self
.rules
:
229 if regexp
.match(filename
):
230 attrs
.update(rhs_dict
)
233 # TODO: Files can belong to multiple groups?
236 # (media_test/all_tests)
238 # How to handle that?
240 def AddRoot(self
, root_path
, alt_name
='_'):
241 """Adds a root directory.
244 root_path: Root directory to add.
245 alt_name: If specified, name of root dir. Otherwise, defaults to '_'.
248 ValueError: alt_name was blank.
250 # Alt name must not be blank. If it were, there wouldn't be a way to
251 # reverse-resolve from a root-replaced path back to the local path, since
252 # '' would always match the beginning of the candidate filename, resulting
253 # in an infinite loop.
255 raise ValueError('AddRoot alt_name must not be blank.')
257 # Clean up root path based on existing rules
258 self
.root_dirs
.append([self
.CleanupFilename(root_path
), alt_name
])
260 def AddRule(self
, path_regexp
, **kwargs
):
264 path_regexp: Regular expression to match for filenames. These are
265 matched after root directory replacement.
266 kwargs: Keyword arguments are attributes to set if the rule applies.
268 Keyword arguments currently supported:
269 include: If True, includes matches; if False, excludes matches. Ignored
271 group: If not None, sets group to apply to matches.
272 language: If not None, sets file language to apply to matches.
275 # Compile regexp ahead of time
276 self
.rules
.append([re
.compile(path_regexp
), dict(kwargs
)])
278 def GetCoveredFile(self
, filename
, add
=False):
279 """Gets the CoveredFile object for the filename.
282 filename: Name of file to find.
283 add: If True, will add the file if it's not present. This applies the
284 transformations from AddRoot() and AddRule(), and only adds the file
285 if a rule includes it, and it has a group and language.
288 The matching CoveredFile object, or None if not present.
291 filename
= self
.CleanupFilename(filename
)
293 # Check for existing match
294 if filename
in self
.files
:
295 return self
.files
[filename
]
297 # File isn't one we know about. If we can't add it, give up.
301 # Check rules to see if file can be added. Files must be included and
302 # have a group and language.
303 attrs
= self
.ClassifyFile(filename
)
304 if not (attrs
.get('include')
305 and attrs
.get('group')
306 and attrs
.get('language')):
310 f
= CoveredFile(filename
, **attrs
)
311 self
.files
[filename
] = f
313 # Return the newly covered file
316 def RemoveCoveredFile(self
, cov_file
):
317 """Removes the file from the covered file list.
320 cov_file: A file object returned by GetCoveredFile().
322 self
.files
.pop(cov_file
.filename
)
324 def ParseLcovData(self
, lcov_data
):
325 """Adds coverage from LCOV-formatted data.
328 lcov_data: An iterable returning lines of data in LCOV format. For
329 example, a file or list of strings.
333 for line
in lcov_data
:
335 if line
.startswith('SF:'):
336 # Start of data for a new file; payload is filename
337 cov_file
= self
.GetCoveredFile(line
[3:], add
=True)
339 cov_lines
= cov_file
.lines
340 cov_file
.in_lcov
= True # File was instrumented
342 # Inside data for a file we don't care about - so skip it
344 elif line
.startswith('DA:'):
345 # Data point - that is, an executable line in current file
346 line_no
, is_covered
= map(int, line
[3:].split(','))
349 cov_lines
[line_no
] = 1
350 elif cov_lines
.get(line_no
) != 1:
351 # Line is not covered, so track it as uncovered
352 cov_lines
[line_no
] = 0
353 elif line
== 'end_of_record':
354 cov_file
.UpdateCoverage()
356 # (else ignore other line types)
358 def ParseLcovFile(self
, input_filename
):
359 """Adds coverage data from a .lcov file.
362 input_filename: Input filename.
364 # TODO: All manner of error checking
367 lcov_file
= open(input_filename
, 'rt')
368 self
.ParseLcovData(lcov_file
)
373 def GetStat(self
, stat
, group
='all', default
=None):
374 """Gets a statistic from the coverage object.
377 stat: Statistic to get. May also be an evaluatable python expression,
378 using the stats. For example, 'stat1 - stat2'.
379 group: File group to match; if 'all', matches all groups.
380 default: Value to return if there was an error evaluating the stat. For
381 example, if the stat does not exist. If None, raises
385 The evaluated stat, or None if error.
388 CrocStatError: Error evaluating stat.
390 # TODO: specify a subdir to get the stat from, then walk the tree to
391 # print the stats from just that subdir
393 # Make sure the group exists
394 if group
not in self
.tree
.stats_by_group
:
396 raise CrocStatError('Group %r not found.' % group
)
400 stats
= self
.tree
.stats_by_group
[group
]
401 # Unit tests use real dicts, not CoverageStats objects,
402 # so we can't AddDefaults() on them.
403 if group
== 'all' and hasattr(stats
, 'AddDefaults'):
406 return eval(stat
, {'__builtins__': {'S': self
.GetStat
}}, stats
)
409 raise CrocStatError('Error evaluating stat %r: %s' % (stat
, e
))
413 def PrintStat(self
, stat
, format
=None, outfile
=sys
.stdout
, **kwargs
):
414 """Prints a statistic from the coverage object.
417 stat: Statistic to get. May also be an evaluatable python expression,
418 using the stats. For example, 'stat1 - stat2'.
419 format: Format string to use when printing stat. If None, prints the
420 stat and its evaluation.
421 outfile: File stream to output stat to; defaults to stdout.
422 kwargs: Additional args to pass to GetStat().
424 s
= self
.GetStat(stat
, **kwargs
)
426 outfile
.write('GetStat(%r) = %s\n' % (stat
, s
))
428 outfile
.write(format
% s
+ '\n')
430 def AddFiles(self
, src_dir
):
431 """Adds files to coverage information.
433 LCOV files only contains files which are compiled and instrumented as part
434 of running coverage. This function finds missing files and adds them.
437 src_dir: Directory on disk at which to start search. May be a relative
438 path on disk starting with '.' or '..', or an absolute path, or a
439 path relative to an alt_name for one of the roots
440 (for example, '_/src'). If the alt_name matches more than one root,
441 all matches will be attempted.
443 Note that dirs not underneath one of the root dirs and covered by an
444 inclusion rule will be ignored.
446 # Check for root dir alt_names in the path and replace with the actual
447 # root dirs, then recurse.
449 for root
, alt_name
in self
.root_dirs
:
450 replaced_root
= re
.sub('^' + re
.escape(alt_name
) + '(?=(/|$))', root
,
452 if replaced_root
!= src_dir
:
454 self
.AddFiles(replaced_root
)
456 return # Replaced an alt_name with a root_dir, so already recursed.
458 for (dirpath
, dirnames
, filenames
) in self
.add_files_walk(src_dir
):
459 # Make a copy of the dirnames list so we can modify the original to
460 # prune subdirs we don't need to walk.
461 for d
in list(dirnames
):
462 # Add trailing '/' to directory names so dir-based regexps can match
463 # '/' instead of needing to specify '(/|$)'.
464 dpath
= self
.CleanupFilename(dirpath
+ '/' + d
) + '/'
465 attrs
= self
.ClassifyFile(dpath
)
466 if not attrs
.get('include'):
467 # Directory has been excluded, so don't traverse it
468 # TODO: Document the slight weirdness caused by this: If you
469 # AddFiles('./A'), and the rules include 'A/B/C/D' but not 'A/B',
470 # then it won't recurse into './A/B' so won't find './A/B/C/D'.
471 # Workarounds are to AddFiles('./A/B/C/D') or AddFiles('./A/B/C').
472 # The latter works because it explicitly walks the contents of the
473 # path passed to AddFiles(), so it finds './A/B/C/D'.
477 local_path
= dirpath
+ '/' + f
479 covf
= self
.GetCoveredFile(local_path
, add
=True)
483 # Save where we found the file, for generating line-by-line HTML output
484 covf
.local_path
= local_path
487 # File already instrumented and doesn't need to be scanned
490 if not covf
.attrs
.get('add_if_missing', 1):
491 # Not allowed to add the file
492 self
.RemoveCoveredFile(covf
)
495 # Scan file to find potentially-executable lines
496 lines
= self
.scan_file(covf
.local_path
, covf
.attrs
.get('language'))
500 covf
.UpdateCoverage()
502 # File has no executable lines, so don't count it
503 self
.RemoveCoveredFile(covf
)
505 def AddConfig(self
, config_data
, lcov_queue
=None, addfiles_queue
=None):
506 """Adds JSON-ish config data.
509 config_data: Config data string.
510 lcov_queue: If not None, object to append lcov_files to instead of
511 parsing them immediately.
512 addfiles_queue: If not None, object to append add_files to instead of
513 processing them immediately.
515 # TODO: All manner of error checking
516 cfg
= eval(config_data
, {'__builtins__': {}}, {})
518 for rootdict
in cfg
.get('roots', []):
519 self
.AddRoot(rootdict
['root'], alt_name
=rootdict
.get('altname', '_'))
521 for ruledict
in cfg
.get('rules', []):
522 regexp
= ruledict
.pop('regexp')
523 self
.AddRule(regexp
, **ruledict
)
525 for add_lcov
in cfg
.get('lcov_files', []):
526 if lcov_queue
is not None:
527 lcov_queue
.append(add_lcov
)
529 self
.ParseLcovFile(add_lcov
)
531 for add_path
in cfg
.get('add_files', []):
532 if addfiles_queue
is not None:
533 addfiles_queue
.append(add_path
)
535 self
.AddFiles(add_path
)
537 self
.print_stats
+= cfg
.get('print_stats', [])
539 def ParseConfig(self
, filename
, **kwargs
):
540 """Parses a configuration file.
543 filename: Config filename.
544 kwargs: Additional parameters to pass to AddConfig().
546 # TODO: All manner of error checking
549 f
= open(filename
, 'rt')
550 # Need to strip CR's from CRLF-terminated lines or posix systems can't
552 config_data
= f
.read().replace('\r\n', '\n')
553 # TODO: some sort of include syntax.
555 # Needs to be done at string-time rather than at eval()-time, so that
556 # it's possible to include parts of dicts. Path from a file to its
557 # include should be relative to the dir containing the file.
559 # Or perhaps it could be done after eval. In that case, there'd be an
560 # 'include' section with a list of files to include. Those would be
561 # eval()'d and recursively pre- or post-merged with the including file.
563 # Or maybe just don't worry about it, since multiple configs can be
564 # specified on the command line.
565 self
.AddConfig(config_data
, **kwargs
)
570 def UpdateTreeStats(self
):
571 """Recalculates the tree stats from the currently covered files.
573 Also calculates coverage summary for files.
575 self
.tree
= CoveredDir('')
576 for cov_file
in self
.files
.itervalues():
577 # Add the file to the tree
578 fdirs
= cov_file
.filename
.split('/')
582 if d
not in parent
.subdirs
:
584 parent
.subdirs
[d
] = CoveredDir(parent
.dirpath
+ '/' + d
)
586 parent
.subdirs
[d
] = CoveredDir(d
)
587 parent
= parent
.subdirs
[d
]
588 ancestors
.append(parent
)
589 # Final subdir actually contains the file
590 parent
.files
[fdirs
[-1]] = cov_file
592 # Now add file's contribution to coverage by dir
595 a
.stats_by_group
['all'].Add(cov_file
.stats
)
597 # Add to group file belongs to
598 group
= cov_file
.attrs
.get('group')
599 if group
not in a
.stats_by_group
:
600 a
.stats_by_group
[group
] = CoverageStats()
601 cbyg
= a
.stats_by_group
[group
]
602 cbyg
.Add(cov_file
.stats
)
605 """Prints the tree stats."""
607 print 'Lines of code coverage by directory:'
608 print self
.tree
.GetTree()
610 #------------------------------------------------------------------------------
617 argv: list of arguments
620 exit code, 0 for normal exit.
623 parser
= optparse
.OptionParser()
625 '-i', '--input', dest
='inputs', type='string', action
='append',
627 help='read LCOV input from FILE')
629 '-r', '--root', dest
='roots', type='string', action
='append',
630 metavar
='ROOT[=ALTNAME]',
631 help='add ROOT directory, optionally map in coverage results as ALTNAME')
633 '-c', '--config', dest
='configs', type='string', action
='append',
635 help='read settings from configuration FILE')
637 '-a', '--addfiles', dest
='addfiles', type='string', action
='append',
639 help='add files from PATH to coverage data')
641 '-t', '--tree', dest
='tree', action
='store_true',
642 help='print tree of code coverage by group')
644 '-u', '--uninstrumented', dest
='uninstrumented', action
='store_true',
645 help='list uninstrumented files')
647 '-m', '--html', dest
='html_out', type='string', metavar
='PATH',
648 help='write HTML output to PATH')
650 '-b', '--base_url', dest
='base_url', type='string', metavar
='URL',
651 help='include URL in base tag of HTML output')
662 options
= parser
.parse_args(args
=argv
)[0]
666 # Set root directories for coverage
667 for root_opt
in options
.roots
:
669 cov
.AddRoot(*root_opt
.split('='))
671 cov
.AddRoot(root_opt
)
674 for config_file
in options
.configs
:
675 cov
.ParseConfig(config_file
, lcov_queue
=options
.inputs
,
676 addfiles_queue
=options
.addfiles
)
679 for input_filename
in options
.inputs
:
680 cov
.ParseLcovFile(input_filename
)
683 for add_path
in options
.addfiles
:
684 cov
.AddFiles(add_path
)
686 # Print help if no files specified
688 print 'No covered files found.'
693 cov
.UpdateTreeStats()
695 # Print uninstrumented filenames
696 if options
.uninstrumented
:
697 print 'Uninstrumented files:'
698 for f
in sorted(cov
.files
):
701 print ' %-6s %-6s %s' % (covf
.attrs
.get('group'),
702 covf
.attrs
.get('language'), f
)
709 for ps_args
in cov
.print_stats
:
710 cov
.PrintStat(**ps_args
)
714 html
= croc_html
.CrocHtml(cov
, options
.html_out
, options
.base_url
)
721 if __name__
== '__main__':
722 sys
.exit(Main(sys
.argv
))