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

Side by Side Diff: tools/code_coverage/croc.py

Issue 113346: Add croc code coverage tool. (Same change as yesterday, but now made in the... (Closed) Base URL: svn://chrome-svn/chrome/trunk/src/
Patch Set: Created 11 years, 7 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 | Annotate | Revision Log
« no previous file with comments | « build/mac/chrome_mac.croc ('k') | tools/code_coverage/croc_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/python2.4
2 #
3 # Copyright 2009, Google Inc.
4 # All rights reserved.
5 #
6 # Redistribution and use in source and binary forms, with or without
7 # modification, are permitted provided that the following conditions are
8 # met:
9 #
10 # * Redistributions of source code must retain the above copyright
11 # notice, this list of conditions and the following disclaimer.
12 # * Redistributions in binary form must reproduce the above
13 # copyright notice, this list of conditions and the following disclaimer
14 # in the documentation and/or other materials provided with the
15 # distribution.
16 # * Neither the name of Google Inc. nor the names of its
17 # contributors may be used to endorse or promote products derived from
18 # this software without specific prior written permission.
19 #
20 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
23 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31
32 """Crocodile - compute coverage numbers for Chrome coverage dashboard."""
33
34 import os
35 import re
36 import sys
37 from optparse import OptionParser
38
39
40 class CoverageError(Exception):
41 """Coverage error."""
42
43 class CoverageStatError(CoverageError):
44 """Error evaluating coverage stat."""
45
46 #------------------------------------------------------------------------------
47
48
49 class CoverageStats(dict):
50 """Coverage statistics."""
51
52 def Add(self, coverage_stats):
53 """Adds a contribution from another coverage stats dict.
54
55 Args:
56 coverage_stats: Statistics to add to this one.
57 """
58 for k, v in coverage_stats.iteritems():
59 if k in self:
60 self[k] = self[k] + v
61 else:
62 self[k] = v
63
64 #------------------------------------------------------------------------------
65
66
67 class CoveredFile(object):
68 """Information about a single covered file."""
69
70 def __init__(self, filename, group, language):
71 """Constructor.
72
73 Args:
74 filename: Full path to file, '/'-delimited.
75 group: Group file belongs to.
76 language: Language for file.
77 """
78 self.filename = filename
79 self.group = group
80 self.language = language
81
82 # No coverage data for file yet
83 self.lines = {} # line_no -> None=executable, 0=instrumented, 1=covered
84 self.stats = CoverageStats()
85
86 def UpdateCoverage(self):
87 """Updates the coverage summary based on covered lines."""
88 exe = instr = cov = 0
89 for l in self.lines.itervalues():
90 exe += 1
91 if l is not None:
92 instr += 1
93 if l == 1:
94 cov += 1
95
96 # Add stats that always exist
97 self.stats = CoverageStats(lines_executable=exe,
98 lines_instrumented=instr,
99 lines_covered=cov,
100 files_executable=1)
101
102 # Add conditional stats
103 if cov:
104 self.stats['files_covered'] = 1
105 if instr:
106 self.stats['files_instrumented'] = 1
107
108
109 #------------------------------------------------------------------------------
110
111
112 class CoveredDir(object):
113 """Information about a directory containing covered files."""
114
115 def __init__(self, dirpath):
116 """Constructor.
117
118 Args:
119 dirpath: Full path of directory, '/'-delimited.
120 """
121 self.dirpath = dirpath
122
123 # List of covered files directly in this dir, indexed by filename (not
124 # full path)
125 self.files = {}
126
127 # List of subdirs, indexed by filename (not full path)
128 self.subdirs = {}
129
130 # Dict of CoverageStats objects summarizing all children, indexed by group
131 self.stats_by_group = {'all':CoverageStats()}
132 # TODO: by language
133
134 def GetTree(self, indent=''):
135 """Recursively gets stats for the directory and its children.
136
137 Args:
138 indent: indent prefix string.
139
140 Returns:
141 The tree as a string.
142 """
143 dest = []
144
145 # Compile all groupstats
146 groupstats = []
147 for group in sorted(self.stats_by_group):
148 s = self.stats_by_group[group]
149 if not s.get('lines_executable'):
150 continue # Skip groups with no executable lines
151 groupstats.append('%s:%d/%d/%d' % (
152 group, s.get('lines_covered', 0),
153 s.get('lines_instrumented', 0),
154 s.get('lines_executable', 0)))
155
156 outline = '%s%-30s %s' % (indent,
157 self.dirpath + '/', ' '.join(groupstats))
158 dest.append(outline.rstrip())
159
160 for d in sorted(self.subdirs):
161 dest.append(self.subdirs[d].GetTree(indent=indent + ' '))
162
163 return '\n'.join(dest)
164
165 #------------------------------------------------------------------------------
166
167
168 class Coverage(object):
169 """Code coverage for a group of files."""
170
171 def __init__(self):
172 """Constructor."""
173 self.files = {} # Map filename --> CoverageFile
174 self.root_dirs = [] # (root, altname)
175 self.rules = [] # (regexp, include, group, language)
176 self.tree = CoveredDir('')
177 self.print_stats = [] # Dicts of args to PrintStat()
178
179 self.add_files_walk = os.walk # Walk function for AddFiles()
180
181 # Must specify subdir rule, or AddFiles() won't find any files because it
182 # will prune out all the subdirs. Since subdirs never match any code,
183 # they won't be reported in other stats, so this is ok.
184 self.AddRule('.*/$', language='subdir')
185
186
187 def CleanupFilename(self, filename):
188 """Cleans up a filename.
189
190 Args:
191 filename: Input filename.
192
193 Returns:
194 The cleaned up filename.
195
196 Changes all path separators to '/'.
197 Makes relative paths (those starting with '../' or './' absolute.
198 Replaces all instances of root dirs with alternate names.
199 """
200 # Change path separators
201 filename = filename.replace('\\', '/')
202
203 # If path is relative, make it absolute
204 # TODO: Perhaps we should default to relative instead, and only understand
205 # absolute to be files starting with '\', '/', or '[A-Za-z]:'?
206 if filename.split('/')[0] in ('.', '..'):
207 filename = os.path.abspath(filename).replace('\\', '/')
208
209 # Replace alternate roots
210 for root, alt_name in self.root_dirs:
211 filename = re.sub('^' + re.escape(root) + '(?=(/|$))',
212 alt_name, filename)
213 return filename
214
215 def ClassifyFile(self, filename):
216 """Applies rules to a filename, to see if we care about it.
217
218 Args:
219 filename: Input filename.
220
221 Returns:
222 (None, None) if the file is not included or has no group or has no
223 language. Otherwise, a 2-tuple containing:
224 The group for the file (for example, 'source' or 'test').
225 The language of the file.
226 """
227 include = False
228 group = None
229 language = None
230
231 # Process all rules
232 for regexp, rule_include, rule_group, rule_language in self.rules:
233 if regexp.match(filename):
234 # include/exclude source
235 if rule_include is not None:
236 include = rule_include
237 if rule_group is not None:
238 group = rule_group
239 if rule_language is not None:
240 language = rule_language
241
242 # TODO: Should have a debug mode which prints files which aren't excluded
243 # and why (explicitly excluded, no type, no language, etc.)
244
245 # TODO: Files can belong to multiple groups?
246 # (test/source)
247 # (mac/pc/win)
248 # (media_test/all_tests)
249 # (small/med/large)
250 # How to handle that?
251
252 # Return classification if the file is included and has a group and
253 # language
254 if include and group and language:
255 return group, language
256 else:
257 return None, None
258
259 def AddRoot(self, root_path, alt_name='#'):
260 """Adds a root directory.
261
262 Args:
263 root_path: Root directory to add.
264 alt_name: If specified, name of root dir
265 """
266 # Clean up root path based on existing rules
267 self.root_dirs.append([self.CleanupFilename(root_path), alt_name])
268
269 def AddRule(self, path_regexp, include=None, group=None, language=None):
270 """Adds a rule.
271
272 Args:
273 path_regexp: Regular expression to match for filenames. These are
274 matched after root directory replacement.
275 include: If True, includes matches; if False, excludes matches. Ignored
276 if None.
277 group: If not None, sets group to apply to matches.
278 language: If not None, sets file language to apply to matches.
279 """
280 # Compile regexp ahead of time
281 self.rules.append([re.compile(path_regexp), include, group, language])
282
283 def GetCoveredFile(self, filename, add=False):
284 """Gets the CoveredFile object for the filename.
285
286 Args:
287 filename: Name of file to find.
288 add: If True, will add the file if it's not present. This applies the
289 transformations from AddRoot() and AddRule(), and only adds the file
290 if a rule includes it, and it has a group and language.
291
292 Returns:
293 The matching CoveredFile object, or None if not present.
294 """
295 # Clean filename
296 filename = self.CleanupFilename(filename)
297
298 # Check for existing match
299 if filename in self.files:
300 return self.files[filename]
301
302 # File isn't one we know about. If we can't add it, give up.
303 if not add:
304 return None
305
306 # Check rules to see if file can be added
307 group, language = self.ClassifyFile(filename)
308 if not group:
309 return None
310
311 # Add the file
312 f = CoveredFile(filename, group, language)
313 self.files[filename] = f
314
315 # Return the newly covered file
316 return f
317
318 def ParseLcovData(self, lcov_data):
319 """Adds coverage from LCOV-formatted data.
320
321 Args:
322 lcov_data: An iterable returning lines of data in LCOV format. For
323 example, a file or list of strings.
324 """
325 cov_file = None
326 cov_lines = None
327 for line in lcov_data:
328 line = line.strip()
329 if line.startswith('SF:'):
330 # Start of data for a new file; payload is filename
331 cov_file = self.GetCoveredFile(line[3:], add=True)
332 if cov_file:
333 cov_lines = cov_file.lines
334 elif not cov_file:
335 # Inside data for a file we don't care about - so skip it
336 pass
337 elif line.startswith('DA:'):
338 # Data point - that is, an executable line in current file
339 line_no, is_covered = map(int, line[3:].split(','))
340 if is_covered:
341 # Line is covered
342 cov_lines[line_no] = 1
343 elif cov_lines.get(line_no) != 1:
344 # Line is not covered, so track it as uncovered
345 cov_lines[line_no] = 0
346 elif line == 'end_of_record':
347 cov_file.UpdateCoverage()
348 cov_file = None
349 # (else ignore other line types)
350
351 def ParseLcovFile(self, input_filename):
352 """Adds coverage data from a .lcov file.
353
354 Args:
355 input_filename: Input filename.
356 """
357 # TODO: All manner of error checking
358 lcov_file = None
359 try:
360 lcov_file = open(input_filename, 'rt')
361 self.ParseLcovData(lcov_file)
362 finally:
363 if lcov_file:
364 lcov_file.close()
365
366 def GetStat(self, stat, group='all', default=None):
367 """Gets a statistic from the coverage object.
368
369 Args:
370 stat: Statistic to get. May also be an evaluatable python expression,
371 using the stats. For example, 'stat1 - stat2'.
372 group: File group to match; if 'all', matches all groups.
373 default: Value to return if there was an error evaluating the stat. For
374 example, if the stat does not exist. If None, raises
375 CoverageStatError.
376
377 Returns:
378 The evaluated stat, or None if error.
379
380 Raises:
381 CoverageStatError: Error evaluating stat.
382 """
383 # TODO: specify a subdir to get the stat from, then walk the tree to
384 # print the stats from just that subdir
385
386 # Make sure the group exists
387 if group not in self.tree.stats_by_group:
388 if default is None:
389 raise CoverageStatError('Group %r not found.' % group)
390 else:
391 return default
392
393 stats = self.tree.stats_by_group[group]
394 try:
395 return eval(stat, {'__builtins__':{'S':self.GetStat}}, stats)
396 except Exception, e:
397 if default is None:
398 raise CoverageStatError('Error evaluating stat %r: %s' % (stat, e))
399 else:
400 return default
401
402 def PrintStat(self, stat, format=None, outfile=sys.stdout, **kwargs):
403 """Prints a statistic from the coverage object.
404
405 Args:
406 stat: Statistic to get. May also be an evaluatable python expression,
407 using the stats. For example, 'stat1 - stat2'.
408 format: Format string to use when printing stat. If None, prints the
409 stat and its evaluation.
410 outfile: File stream to output stat to; defaults to stdout.
411 kwargs: Additional args to pass to GetStat().
412 """
413 s = self.GetStat(stat, **kwargs)
414 if format is None:
415 outfile.write('GetStat(%r) = %s\n' % (stat, s))
416 else:
417 outfile.write(format % s + '\n')
418
419 def AddFiles(self, src_dir):
420 """Adds files to coverage information.
421
422 LCOV files only contains files which are compiled and instrumented as part
423 of running coverage. This function finds missing files and adds them.
424
425 Args:
426 src_dir: Directory on disk at which to start search. May be a relative
427 path on disk starting with '.' or '..', or an absolute path, or a
428 path relative to an alt_name for one of the roots
429 (for example, '#/src'). If the alt_name matches more than one root,
430 all matches will be attempted.
431
432 Note that dirs not underneath one of the root dirs and covered by an
433 inclusion rule will be ignored.
434 """
435 # Check for root dir alt_names in the path and replace with the actual
436 # root dirs, then recurse.
437 found_root = False
438 for root, alt_name in self.root_dirs:
439 replaced_root = re.sub('^' + re.escape(alt_name) + '(?=(/|$))', root,
440 src_dir)
441 if replaced_root != src_dir:
442 found_root = True
443 self.AddFiles(replaced_root)
444 if found_root:
445 return # Replaced an alt_name with a root_dir, so already recursed.
446
447 for (dirpath, dirnames, filenames) in self.add_files_walk(src_dir):
448 # Make a copy of the dirnames list so we can modify the original to
449 # prune subdirs we don't need to walk.
450 for d in list(dirnames):
451 # Add trailing '/' to directory names so dir-based regexps can match
452 # '/' instead of needing to specify '(/|$)'.
453 dpath = self.CleanupFilename(dirpath + '/' + d) + '/'
454 group, language = self.ClassifyFile(dpath)
455 if not group:
456 # Directory has been excluded, so don't traverse it
457 # TODO: Document the slight weirdness caused by this: If you
458 # AddFiles('./A'), and the rules include 'A/B/C/D' but not 'A/B',
459 # then it won't recurse into './A/B' so won't find './A/B/C/D'.
460 # Workarounds are to AddFiles('./A/B/C/D') or AddFiles('./A/B/C').
461 # The latter works because it explicitly walks the contents of the
462 # path passed to AddFiles(), so it finds './A/B/C/D'.
463 dirnames.remove(d)
464
465 for f in filenames:
466 covf = self.GetCoveredFile(dirpath + '/' + f, add=True)
467 # TODO: scan files for executable lines. Add these to the file as
468 # 'executable', but not 'instrumented' or 'covered'.
469 # TODO: if a file has no executable lines, don't add it.
470 if covf:
471 covf.UpdateCoverage()
472
473 def AddConfig(self, config_data, lcov_queue=None, addfiles_queue=None):
474 """Adds JSON-ish config data.
475
476 Args:
477 config_data: Config data string.
478 lcov_queue: If not None, object to append lcov_files to instead of
479 parsing them immediately.
480 addfiles_queue: If not None, object to append add_files to instead of
481 processing them immediately.
482 """
483 # TODO: All manner of error checking
484 cfg = eval(config_data, {'__builtins__':{}}, {})
485
486 for rootdict in cfg.get('roots', []):
487 self.AddRoot(rootdict['root'], alt_name=rootdict.get('altname', '#'))
488
489 for ruledict in cfg.get('rules', []):
490 self.AddRule(ruledict['regexp'],
491 include=ruledict.get('include'),
492 group=ruledict.get('group'),
493 language=ruledict.get('language'))
494
495 for add_lcov in cfg.get('lcov_files', []):
496 if lcov_queue is not None:
497 lcov_queue.append(add_lcov)
498 else:
499 self.ParseLcovFile(add_lcov)
500
501 for add_path in cfg.get('add_files', []):
502 if addfiles_queue is not None:
503 addfiles_queue.append(add_path)
504 else:
505 self.AddFiles(add_path)
506
507 self.print_stats += cfg.get('print_stats', [])
508
509 def ParseConfig(self, filename, **kwargs):
510 """Parses a configuration file.
511
512 Args:
513 filename: Config filename.
514 """
515 # TODO: All manner of error checking
516 f = None
517 try:
518 f = open(filename, 'rt')
519 # Need to strip CR's from CRLF-terminated lines or posix systems can't
520 # eval the data.
521 config_data = f.read().replace('\r\n', '\n')
522 # TODO: some sort of include syntax. Needs to be done at string-time
523 # rather than at eval()-time, so that it's possible to include parts of
524 # dicts. Path from a file to its include should be relative to the dir
525 # containing the file.
526 self.AddConfig(config_data, **kwargs)
527 finally:
528 if f:
529 f.close()
530
531 def UpdateTreeStats(self):
532 """Recalculates the tree stats from the currently covered files.
533
534 Also calculates coverage summary for files."""
535 self.tree = CoveredDir('')
536 for cov_file in self.files.itervalues():
537 # Add the file to the tree
538 # TODO: Don't really need to create the tree unless we're creating HTML
539 fdirs = cov_file.filename.split('/')
540 parent = self.tree
541 ancestors = [parent]
542 for d in fdirs[:-1]:
543 if d not in parent.subdirs:
544 parent.subdirs[d] = CoveredDir(d)
545 parent = parent.subdirs[d]
546 ancestors.append(parent)
547 # Final subdir actually contains the file
548 parent.files[fdirs[-1]] = cov_file
549
550 # Now add file's contribution to coverage by dir
551 for a in ancestors:
552 # Add to 'all' group
553 a.stats_by_group['all'].Add(cov_file.stats)
554
555 # Add to group file belongs to
556 if cov_file.group not in a.stats_by_group:
557 a.stats_by_group[cov_file.group] = CoverageStats()
558 cbyg = a.stats_by_group[cov_file.group]
559 cbyg.Add(cov_file.stats)
560
561 def PrintTree(self):
562 """Prints the tree stats."""
563 # Print the tree
564 print 'Lines of code coverage by directory:'
565 print self.tree.GetTree()
566
567 #------------------------------------------------------------------------------
568
569
570 def Main(argv):
571 """Main routine.
572
573 Args:
574 argv: list of arguments
575
576 Returns:
577 exit code, 0 for normal exit.
578 """
579 # Parse args
580 parser = OptionParser()
581 parser.add_option(
582 '-i', '--input', dest='inputs', type='string', action='append',
583 metavar='FILE',
584 help='read LCOV input from FILE')
585 parser.add_option(
586 '-r', '--root', dest='roots', type='string', action='append',
587 metavar='ROOT[=ALTNAME]',
588 help='add ROOT directory, optionally map in coverage results as ALTNAME')
589 parser.add_option(
590 '-c', '--config', dest='configs', type='string', action='append',
591 metavar='FILE',
592 help='read settings from configuration FILE')
593 parser.add_option(
594 '-a', '--addfiles', dest='addfiles', type='string', action='append',
595 metavar='PATH',
596 help='add files from PATH to coverage data')
597 parser.add_option(
598 '-t', '--tree', dest='tree', action='store_true',
599 help='print tree of code coverage by group')
600 parser.add_option(
601 '-u', '--uninstrumented', dest='uninstrumented', action='store_true',
602 help='list uninstrumented files')
603
604 parser.set_defaults(
605 inputs=[],
606 roots=[],
607 configs=[],
608 addfiles=[],
609 tree=False,
610 )
611
612 (options, args) = parser.parse_args()
613
614 cov = Coverage()
615
616 # Set root directories for coverage
617 for root_opt in options.roots:
618 if '=' in root_opt:
619 cov.AddRoot(*root_opt.split('='))
620 else:
621 cov.AddRoot(root_opt)
622
623 # Read config files
624 for config_file in options.configs:
625 cov.ParseConfig(config_file, lcov_queue=options.inputs,
626 addfiles_queue=options.addfiles)
627
628 # Parse lcov files
629 for input_filename in options.inputs:
630 cov.ParseLcovFile(input_filename)
631
632 # Add missing files
633 for add_path in options.addfiles:
634 cov.AddFiles(add_path)
635
636 # Print help if no files specified
637 if not cov.files:
638 print 'No covered files found.'
639 parser.print_help()
640 return 1
641
642 # Update tree stats
643 cov.UpdateTreeStats()
644
645 # Print uninstrumented filenames
646 if options.uninstrumented:
647 print 'Uninstrumented files:'
648 for f in sorted(cov.files):
649 covf = cov.files[f]
650 if not covf.stats.get('lines_instrumented'):
651 print ' %-6s %-6s %s' % (covf.group, covf.language, f)
652
653
654 # Print tree stats
655 if options.tree:
656 cov.PrintTree()
657
658 # Print stats
659 for ps_args in cov.print_stats:
660 cov.PrintStat(**ps_args)
661
662 # Normal exit
663 return 0
664
665
666 #------------------------------------------------------------------------------
667
668 if __name__ == '__main__':
669 sys.exit(Main(sys.argv))
OLDNEW
« no previous file with comments | « build/mac/chrome_mac.croc ('k') | tools/code_coverage/croc_test.py » ('j') | no next file with comments »

Powered by Google App Engine
This is Rietveld 408576698