diff --git a/coverage/annotate.py b/coverage/annotate.py index 4060450ff..a3d2d6790 100644 --- a/coverage/annotate.py +++ b/coverage/annotate.py @@ -7,6 +7,8 @@ import os import re +from coverage import files + from coverage.files import flat_rootname from coverage.misc import isolate_module from coverage.report import Reporter @@ -36,31 +38,87 @@ class AnnotateReporter(Reporter): """ - def __init__(self, coverage, config): + def __init__(self, coverage, config, lcov_file=None): super(AnnotateReporter, self).__init__(coverage, config) self.directory = None blank_re = re.compile(r"\s*(#|$)") else_re = re.compile(r"\s*else\s*:\s*(#|$)") - def report(self, morfs, directory=None): + def report(self, morfs, directory=None, lcov_file=None): """Run the report. See `coverage.report()` for arguments. """ - self.report_files(self.annotate_file, morfs, directory) - - def annotate_file(self, fr, analysis): + self.report_files(self.annotate_file, morfs, directory, + lcov_file=lcov_file) + + def print_ba_lines(self, lcov_file, analysis): + # This function takes advantage of the fact that the functions + # in Analysis returns arcs in sorted order of their line numbers + # and mark the branch numbers in the same order of the + # destination line numbers of the arcs. For example: + # + # Line + # 10 if (something): + # 11 do_something + # 12 else: + # 13 do_something_else + # + # In the coverage analysis result, the tool returns arc list [ + # ... (10,11), (10, 13) ...]. We will then regard (10,11) as + # the first branch at line 10 and (10, 13) as the second branch + # at line 10. This is important as in lcov file the branch + # coverage info must appear in order, e.g., suppose the test + # code executes the 'if' branch, the results in lcov format will + # be + # + # BA: 10, 2 (the first branch is taken) + # BA: 10, 1 (the second branch is executed but not taken) + # + # Note that in other languages the branch ordering might be + # treated differently. + + all_arcs = analysis.arc_possibilities() + branch_lines = set(analysis.branch_lines()) + missing_branch_arcs = analysis.missing_branch_arcs() + missing = analysis.missing + + for source_line,target_line in all_arcs: + if source_line in branch_lines: + if source_line in missing: + # Not executed + lcov_file.write('BA:%d,0\n' % source_line) + else: + if (source_line in missing_branch_arcs) and ( + target_line in missing_branch_arcs[source_line]): + # Executed and not taken + lcov_file.write('BA:%d,1\n' % source_line) + else: + # Executed and taken + lcov_file.write('BA:%d,2\n' % source_line) + + (MISSED, COVERED, BLANK, EXCLUDED) = ('!', '>', ' ', '-') + def annotate_file(self, fr, analysis, lcov_file=None): """Annotate a single file. `fr` is the FileReporter for the file to annotate. """ + reverse_mapping = files.get_filename_from_cf(fr.filename) + + filename = fr.filename + if reverse_mapping: + filename = reverse_mapping + statements = sorted(analysis.statements) missing = sorted(analysis.missing) excluded = sorted(analysis.excluded) + if lcov_file: + lcov_file.write("SF:%s\n" % filename) + if self.directory: dest_file = os.path.join(self.directory, flat_rootname(fr.relative_filename())) if dest_file.endswith("_py"): @@ -69,7 +127,11 @@ def annotate_file(self, fr, analysis): else: dest_file = fr.filename + ",cover" - with io.open(dest_file, 'w', encoding='utf8') as dest: + if not lcov_file: + dest = io.open(dest_file, 'w', encoding='utf8') + + if True: # GOOGLE: force indent for easy comparison; original: + # with io.open(dest_file, 'w', encoding='utf8') as dest: i = 0 j = 0 covered = True @@ -82,22 +144,36 @@ def annotate_file(self, fr, analysis): if i < len(statements) and statements[i] == lineno: covered = j >= len(missing) or missing[j] > lineno if self.blank_re.match(line): - dest.write(u' ') + line_type = self.BLANK elif self.else_re.match(line): # Special logic for lines containing only 'else:'. if i >= len(statements) and j >= len(missing): - dest.write(u'! ') + line_type = self.MISSED elif i >= len(statements) or j >= len(missing): - dest.write(u'> ') + line_type = self.COVERED elif statements[i] == missing[j]: - dest.write(u'! ') + line_type = self.MISSED else: - dest.write(u'> ') + line_type = self.COVERED elif lineno in excluded: - dest.write(u'- ') + line_type = self.EXCLUDED elif covered: - dest.write(u'> ') + line_type = self.COVERED else: - dest.write(u'! ') + line_type = self.MISSED - dest.write(line) + if not lcov_file: + dest.write("%s %s" % (line_type, line)) + else: + # Omit BLANK & EXCLUDED line types from this lcov output type. + if line_type == self.COVERED: + lcov_file.write("DA:%d,1\n" % lineno) + elif line_type == self.MISSED: + lcov_file.write("DA:%d,0\n" % lineno) + # Write branch coverage results + if lcov_file and analysis.has_arcs(): + self.print_ba_lines(lcov_file, analysis) + if lcov_file: + lcov_file.write("end_of_record\n") + else: + dest.close() # XXX try: finally: more "appropriate" than "if True" diff --git a/coverage/cmdline.py b/coverage/cmdline.py index 79d0f26b9..193da54ea 100644 --- a/coverage/cmdline.py +++ b/coverage/cmdline.py @@ -70,6 +70,11 @@ class Opts(object): "Accepts shell-style wildcards, which must be quoted." ), ) + lcov = optparse.make_option( + '-l', '--lcov', action='store', dest="lcov_file", + metavar="OUTFILE", + help="report in lcov format" + ) pylib = optparse.make_option( '-L', '--pylib', action='store_true', help=( @@ -271,6 +276,7 @@ def get_prog_name(self): Opts.ignore_errors, Opts.include, Opts.omit, + Opts.lcov, ] + GLOBAL_ARGS, usage="[options] [modules]", description=( @@ -363,6 +369,7 @@ def get_prog_name(self): Opts.parallel_mode, Opts.source, Opts.timid, + Opts.lcov, ] + GLOBAL_ARGS, usage="[options] [program options]", description="Run a Python program, measuring code execution." @@ -499,11 +506,18 @@ def command_line(self, argv): return OK # Remaining actions are reporting, with some common options. + if options.lcov_file: + lcovfile = open(options.lcov_file, "w") + else: + print("No Report generated: missing --lcov= argument") + return ERR + report_args = dict( morfs=unglob_args(args), ignore_errors=options.ignore_errors, omit=omit, include=include, + lcov_file = lcovfile, ) self.coverage.load() diff --git a/coverage/control.py b/coverage/control.py index cf65d6581..3ef41ebe2 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -23,7 +23,7 @@ from coverage.debug import DebugControl, write_formatted_info from coverage.files import TreeMatcher, FnmatchMatcher from coverage.files import PathAliases, find_python_files, prep_patterns -from coverage.files import canonical_filename, set_relative_directory +from coverage.files import canonical_filename, set_relative_directory, unicode_filename from coverage.files import ModuleMatcher, abs_file from coverage.html import HtmlReporter from coverage.misc import CoverageException, bool_or_none, join_regex @@ -59,6 +59,17 @@ except ImportError: pass +# Note +# This log might be lost when called in atexit functions. In some +# tests sys.stderr is closed when the atexit handler is called. At that +# point we have to discard the message because otherwise it causes an +# immediate exit with error code 1. +def coverage_log(str): + """Write to stderr the provided string, if stderr is still open.""" + if sys.stderr.closed: + return + sys.stderr.write(str + '\n') + class Coverage(object): """Programmatic access to coverage.py. @@ -623,7 +634,7 @@ def _warn(self, msg, slug=None): msg = "%s (%s)" % (msg, slug) if self.debug.should('pid'): msg = "[%d] %s" % (os.getpid(), msg) - sys.stderr.write("Coverage.py warning: %s\n" % msg) + coverage_log("Coverage.py warning: %s" % msg) def get_option(self, option_name): """Get an option from the configuration. @@ -715,6 +726,18 @@ def _atexit(self): self.stop() if self._auto_save: self.save() + lcov_filename = os.environ.get('PYTHON_LCOV_FILE') + if lcov_filename: + try: + with open(lcov_filename, 'w') as lcov_file: + try: + self.annotate(lcov_file=lcov_file, + ignore_errors=True) + except CoverageException as e: + coverage_log('Coverage error %s'% e) + except IOError as e: + coverage_log('Error (%s) creating lcov file %s' % + (e.strerror, lcov_filename)) def erase(self): """Erase previously-collected coverage data. @@ -1016,7 +1039,7 @@ def _get_file_reporters(self, morfs=None): def report( self, morfs=None, show_missing=None, ignore_errors=None, file=None, # pylint: disable=redefined-builtin - omit=None, include=None, skip_covered=None, + omit=None, include=None, skip_covered=None, lcov_file=None ): """Write a summary report to `file`. @@ -1038,11 +1061,11 @@ def report( show_missing=show_missing, skip_covered=skip_covered, ) reporter = SummaryReporter(self, self.config) - return reporter.report(morfs, outfile=file) + return reporter.report(morfs, outfile=file, lcov_file=lcov_file) def annotate( self, morfs=None, directory=None, ignore_errors=None, - omit=None, include=None, + omit=None, include=None, lcov_file=None, ): """Annotate a list of modules. @@ -1059,11 +1082,11 @@ def annotate( ignore_errors=ignore_errors, report_omit=omit, report_include=include ) reporter = AnnotateReporter(self, self.config) - reporter.report(morfs, directory=directory) + reporter.report(morfs, directory=directory, lcov_file=lcov_file) def html_report(self, morfs=None, directory=None, ignore_errors=None, omit=None, include=None, extra_css=None, title=None, - skip_covered=None): + skip_covered=None, lcov_file=None): """Generate an HTML report. The HTML is written to `directory`. The file "index.html" is the @@ -1092,7 +1115,7 @@ def html_report(self, morfs=None, directory=None, ignore_errors=None, def xml_report( self, morfs=None, outfile=None, ignore_errors=None, - omit=None, include=None, + omit=None, include=None, lcov_file=None, ): """Generate an XML report of coverage results. diff --git a/coverage/files.py b/coverage/files.py index 7ab60e45d..58c36f503 100644 --- a/coverage/files.py +++ b/coverage/files.py @@ -51,6 +51,14 @@ def relative_filename(filename): return unicode_filename(filename) +def get_filename_from_cf(cf): + """Return the reverse mapping of canonical_filename.""" + for f in CANONICAL_FILENAME_CACHE.keys(): + if CANONICAL_FILENAME_CACHE[f] == cf and not f == cf: + return f + return None + + @contract(returns='unicode') def canonical_filename(filename): """Return a canonical file name for `filename`. diff --git a/coverage/report.py b/coverage/report.py index b46086339..6e5ede48f 100644 --- a/coverage/report.py +++ b/coverage/report.py @@ -65,7 +65,7 @@ def find_file_reporters(self, morfs): self._file_reporters = sorted(reporters) return self._file_reporters - def report_files(self, report_fn, morfs, directory=None): + def report_files(self, report_fn, morfs, directory=None, lcov_file=None): """Run a reporting function on a number of morfs. `report_fn` is called for each relative morf in `morfs`. It is called @@ -88,7 +88,7 @@ def report_files(self, report_fn, morfs, directory=None): for fr in file_reporters: try: - report_fn(fr, self.coverage._analyze(fr)) + report_fn(fr, self.coverage._analyze(fr), lcov_file=lcov_file) except NoSource: if not self.config.ignore_errors: raise diff --git a/coverage/summary.py b/coverage/summary.py index 271b648a8..7de9c9c84 100644 --- a/coverage/summary.py +++ b/coverage/summary.py @@ -18,7 +18,7 @@ def __init__(self, coverage, config): super(SummaryReporter, self).__init__(coverage, config) self.branches = coverage.data.has_arcs() - def report(self, morfs, outfile=None): + def report(self, morfs, outfile=None, lcov_file=None): """Writes a report summarizing coverage statistics per module. `outfile` is a file object to write the summary to. It must be opened