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