1# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 2# For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt 3 4"""Source file annotation for coverage.py.""" 5 6import io 7import os 8import re 9 10from coverage.files import flat_rootname 11from coverage.misc import isolate_module 12from coverage.report import Reporter 13 14os = isolate_module(os) 15 16 17class AnnotateReporter(Reporter): 18 """Generate annotated source files showing line coverage. 19 20 This reporter creates annotated copies of the measured source files. Each 21 .py file is copied as a .py,cover file, with a left-hand margin annotating 22 each line:: 23 24 > def h(x): 25 - if 0: #pragma: no cover 26 - pass 27 > if x == 1: 28 ! a = 1 29 > else: 30 > a = 2 31 32 > h(2) 33 34 Executed lines use '>', lines not executed use '!', lines excluded from 35 consideration use '-'. 36 37 """ 38 39 def __init__(self, coverage, config): 40 super(AnnotateReporter, self).__init__(coverage, config) 41 self.directory = None 42 43 blank_re = re.compile(r"\s*(#|$)") 44 else_re = re.compile(r"\s*else\s*:\s*(#|$)") 45 46 def report(self, morfs, directory=None): 47 """Run the report. 48 49 See `coverage.report()` for arguments. 50 51 """ 52 self.report_files(self.annotate_file, morfs, directory) 53 54 def annotate_file(self, fr, analysis): 55 """Annotate a single file. 56 57 `fr` is the FileReporter for the file to annotate. 58 59 """ 60 statements = sorted(analysis.statements) 61 missing = sorted(analysis.missing) 62 excluded = sorted(analysis.excluded) 63 64 if self.directory: 65 dest_file = os.path.join(self.directory, flat_rootname(fr.relative_filename())) 66 if dest_file.endswith("_py"): 67 dest_file = dest_file[:-3] + ".py" 68 dest_file += ",cover" 69 else: 70 dest_file = fr.filename + ",cover" 71 72 with io.open(dest_file, 'w', encoding='utf8') as dest: 73 i = 0 74 j = 0 75 covered = True 76 source = fr.source() 77 for lineno, line in enumerate(source.splitlines(True), start=1): 78 while i < len(statements) and statements[i] < lineno: 79 i += 1 80 while j < len(missing) and missing[j] < lineno: 81 j += 1 82 if i < len(statements) and statements[i] == lineno: 83 covered = j >= len(missing) or missing[j] > lineno 84 if self.blank_re.match(line): 85 dest.write(u' ') 86 elif self.else_re.match(line): 87 # Special logic for lines containing only 'else:'. 88 if i >= len(statements) and j >= len(missing): 89 dest.write(u'! ') 90 elif i >= len(statements) or j >= len(missing): 91 dest.write(u'> ') 92 elif statements[i] == missing[j]: 93 dest.write(u'! ') 94 else: 95 dest.write(u'> ') 96 elif lineno in excluded: 97 dest.write(u'- ') 98 elif covered: 99 dest.write(u'> ') 100 else: 101 dest.write(u'! ') 102 103 dest.write(line) 104