From f0d5c914c1db071dee9e17d5704e34495b36d1d6 Mon Sep 17 00:00:00 2001 From: "Gregory P. Smith [Google LLC]" Date: Mon, 28 Oct 2019 17:04:19 -0700 Subject: [PATCH] Google's patched-on LCOV support for coveragepy 4. This is gross, it was done in a way that minized diffs. Reworking this to be non-ugly code for proper support in coveragepy would be great. I'm making the PR on a branch of v4.5.x as our existing changes do not apply cleanly after some of the refactoring in the git master (v5) code. We're still using patched 4.4.2 internally at the time of PR creation. --- coverage/annotate.py | 106 +++++++++++++++++++++++++++++++++++++------ coverage/cmdline.py | 14 ++++++ coverage/control.py | 39 ++++++++++++---- coverage/files.py | 8 ++++ coverage/report.py | 4 +- coverage/summary.py | 2 +- 6 files changed, 147 insertions(+), 26 deletions(-) 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