--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/DebugClients/Python/coverage/html.py Mon Sep 19 22:47:52 2016 +0200 @@ -0,0 +1,435 @@ +# Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 +# For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt + +"""HTML reporting for coverage.py.""" + +import datetime +import json +import os +import shutil + +import coverage +from coverage import env +from coverage.backward import iitems +from coverage.files import flat_rootname +from coverage.misc import CoverageException, Hasher, isolate_module +from coverage.report import Reporter +from coverage.results import Numbers +from coverage.templite import Templite + +os = isolate_module(os) + + +# Static files are looked for in a list of places. +STATIC_PATH = [ + # The place Debian puts system Javascript libraries. + "/usr/share/javascript", + + # Our htmlfiles directory. + os.path.join(os.path.dirname(__file__), "htmlfiles"), +] + + +def data_filename(fname, pkgdir=""): + """Return the path to a data file of ours. + + The file is searched for on `STATIC_PATH`, and the first place it's found, + is returned. + + Each directory in `STATIC_PATH` is searched as-is, and also, if `pkgdir` + is provided, at that sub-directory. + + """ + tried = [] + for static_dir in STATIC_PATH: + static_filename = os.path.join(static_dir, fname) + if os.path.exists(static_filename): + return static_filename + else: + tried.append(static_filename) + if pkgdir: + static_filename = os.path.join(static_dir, pkgdir, fname) + if os.path.exists(static_filename): + return static_filename + else: + tried.append(static_filename) + raise CoverageException( + "Couldn't find static file %r from %r, tried: %r" % (fname, os.getcwd(), tried) + ) + + +def read_data(fname): + """Return the contents of a data file of ours.""" + with open(data_filename(fname)) as data_file: + return data_file.read() + + +def write_html(fname, html): + """Write `html` to `fname`, properly encoded.""" + with open(fname, "wb") as fout: + fout.write(html.encode('ascii', 'xmlcharrefreplace')) + + +class HtmlReporter(Reporter): + """HTML reporting.""" + + # These files will be copied from the htmlfiles directory to the output + # directory. + STATIC_FILES = [ + ("style.css", ""), + ("jquery.min.js", "jquery"), + ("jquery.debounce.min.js", "jquery-debounce"), + ("jquery.hotkeys.js", "jquery-hotkeys"), + ("jquery.isonscreen.js", "jquery-isonscreen"), + ("jquery.tablesorter.min.js", "jquery-tablesorter"), + ("coverage_html.js", ""), + ("keybd_closed.png", ""), + ("keybd_open.png", ""), + ] + + def __init__(self, cov, config): + super(HtmlReporter, self).__init__(cov, config) + self.directory = None + title = self.config.html_title + if env.PY2: + title = title.decode("utf8") + self.template_globals = { + 'escape': escape, + 'pair': pair, + 'title': title, + '__url__': coverage.__url__, + '__version__': coverage.__version__, + } + self.source_tmpl = Templite(read_data("pyfile.html"), self.template_globals) + + self.coverage = cov + + self.files = [] + self.has_arcs = self.coverage.data.has_arcs() + self.status = HtmlStatus() + self.extra_css = None + self.totals = Numbers() + self.time_stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M') + + def report(self, morfs): + """Generate an HTML report for `morfs`. + + `morfs` is a list of modules or file names. + + """ + assert self.config.html_dir, "must give a directory for html reporting" + + # Read the status data. + self.status.read(self.config.html_dir) + + # Check that this run used the same settings as the last run. + m = Hasher() + m.update(self.config) + these_settings = m.hexdigest() + if self.status.settings_hash() != these_settings: + self.status.reset() + self.status.set_settings_hash(these_settings) + + # The user may have extra CSS they want copied. + if self.config.extra_css: + self.extra_css = os.path.basename(self.config.extra_css) + + # Process all the files. + self.report_files(self.html_file, morfs, self.config.html_dir) + + if not self.files: + raise CoverageException("No data to report.") + + # Write the index file. + self.index_file() + + self.make_local_static_report_files() + return self.totals.n_statements and self.totals.pc_covered + + def make_local_static_report_files(self): + """Make local instances of static files for HTML report.""" + # The files we provide must always be copied. + for static, pkgdir in self.STATIC_FILES: + shutil.copyfile( + data_filename(static, pkgdir), + os.path.join(self.directory, static) + ) + + # The user may have extra CSS they want copied. + if self.extra_css: + shutil.copyfile( + self.config.extra_css, + os.path.join(self.directory, self.extra_css) + ) + + def file_hash(self, source, fr): + """Compute a hash that changes if the file needs to be re-reported.""" + m = Hasher() + m.update(source) + self.coverage.data.add_to_hash(fr.filename, m) + return m.hexdigest() + + def html_file(self, fr, analysis): + """Generate an HTML file for one source file.""" + source = fr.source() + + # Find out if the file on disk is already correct. + rootname = flat_rootname(fr.relative_filename()) + this_hash = self.file_hash(source.encode('utf-8'), fr) + that_hash = self.status.file_hash(rootname) + if this_hash == that_hash: + # Nothing has changed to require the file to be reported again. + self.files.append(self.status.index_info(rootname)) + return + + self.status.set_file_hash(rootname, this_hash) + + # Get the numbers for this file. + nums = analysis.numbers + + if self.has_arcs: + missing_branch_arcs = analysis.missing_branch_arcs() + arcs_executed = analysis.arcs_executed() + + # These classes determine which lines are highlighted by default. + c_run = "run hide_run" + c_exc = "exc" + c_mis = "mis" + c_par = "par " + c_run + + lines = [] + + for lineno, line in enumerate(fr.source_token_lines(), start=1): + # Figure out how to mark this line. + line_class = [] + annotate_html = "" + annotate_long = "" + if lineno in analysis.statements: + line_class.append("stm") + if lineno in analysis.excluded: + line_class.append(c_exc) + elif lineno in analysis.missing: + line_class.append(c_mis) + elif self.has_arcs and lineno in missing_branch_arcs: + line_class.append(c_par) + shorts = [] + longs = [] + for b in missing_branch_arcs[lineno]: + if b < 0: + shorts.append("exit") + else: + shorts.append(b) + longs.append(fr.missing_arc_description(lineno, b, arcs_executed)) + # 202F is NARROW NO-BREAK SPACE. + # 219B is RIGHTWARDS ARROW WITH STROKE. + short_fmt = "%s ↛ %s" + annotate_html = ", ".join(short_fmt % (lineno, d) for d in shorts) + + if len(longs) == 1: + annotate_long = longs[0] + else: + annotate_long = "%d missed branches: %s" % ( + len(longs), + ", ".join("%d) %s" % (num, ann_long) + for num, ann_long in enumerate(longs, start=1)), + ) + elif lineno in analysis.statements: + line_class.append(c_run) + + # Build the HTML for the line. + html = [] + for tok_type, tok_text in line: + if tok_type == "ws": + html.append(escape(tok_text)) + else: + tok_html = escape(tok_text) or ' ' + html.append( + '<span class="%s">%s</span>' % (tok_type, tok_html) + ) + + lines.append({ + 'html': ''.join(html), + 'number': lineno, + 'class': ' '.join(line_class) or "pln", + 'annotate': annotate_html, + 'annotate_long': annotate_long, + }) + + # Write the HTML page for this file. + html = self.source_tmpl.render({ + 'c_exc': c_exc, + 'c_mis': c_mis, + 'c_par': c_par, + 'c_run': c_run, + 'has_arcs': self.has_arcs, + 'extra_css': self.extra_css, + 'fr': fr, + 'nums': nums, + 'lines': lines, + 'time_stamp': self.time_stamp, + }) + + html_filename = rootname + ".html" + html_path = os.path.join(self.directory, html_filename) + write_html(html_path, html) + + # Save this file's information for the index file. + index_info = { + 'nums': nums, + 'html_filename': html_filename, + 'relative_filename': fr.relative_filename(), + } + self.files.append(index_info) + self.status.set_index_info(rootname, index_info) + + def index_file(self): + """Write the index.html file for this report.""" + index_tmpl = Templite(read_data("index.html"), self.template_globals) + + self.totals = sum(f['nums'] for f in self.files) + + html = index_tmpl.render({ + 'has_arcs': self.has_arcs, + 'extra_css': self.extra_css, + 'files': self.files, + 'totals': self.totals, + 'time_stamp': self.time_stamp, + }) + + write_html(os.path.join(self.directory, "index.html"), html) + + # Write the latest hashes for next time. + self.status.write(self.directory) + + +class HtmlStatus(object): + """The status information we keep to support incremental reporting.""" + + STATUS_FILE = "status.json" + STATUS_FORMAT = 1 + + # pylint: disable=wrong-spelling-in-comment,useless-suppression + # The data looks like: + # + # { + # 'format': 1, + # 'settings': '540ee119c15d52a68a53fe6f0897346d', + # 'version': '4.0a1', + # 'files': { + # 'cogapp___init__': { + # 'hash': 'e45581a5b48f879f301c0f30bf77a50c', + # 'index': { + # 'html_filename': 'cogapp___init__.html', + # 'name': 'cogapp/__init__', + # 'nums': <coverage.results.Numbers object at 0x10ab7ed0>, + # } + # }, + # ... + # 'cogapp_whiteutils': { + # 'hash': '8504bb427fc488c4176809ded0277d51', + # 'index': { + # 'html_filename': 'cogapp_whiteutils.html', + # 'name': 'cogapp/whiteutils', + # 'nums': <coverage.results.Numbers object at 0x10ab7d90>, + # } + # }, + # }, + # } + + def __init__(self): + self.reset() + + def reset(self): + """Initialize to empty.""" + self.settings = '' + self.files = {} + + def read(self, directory): + """Read the last status in `directory`.""" + usable = False + try: + status_file = os.path.join(directory, self.STATUS_FILE) + with open(status_file, "r") as fstatus: + status = json.load(fstatus) + except (IOError, ValueError): + usable = False + else: + usable = True + if status['format'] != self.STATUS_FORMAT: + usable = False + elif status['version'] != coverage.__version__: + usable = False + + if usable: + self.files = {} + for filename, fileinfo in iitems(status['files']): + fileinfo['index']['nums'] = Numbers(*fileinfo['index']['nums']) + self.files[filename] = fileinfo + self.settings = status['settings'] + else: + self.reset() + + def write(self, directory): + """Write the current status to `directory`.""" + status_file = os.path.join(directory, self.STATUS_FILE) + files = {} + for filename, fileinfo in iitems(self.files): + fileinfo['index']['nums'] = fileinfo['index']['nums'].init_args() + files[filename] = fileinfo + + status = { + 'format': self.STATUS_FORMAT, + 'version': coverage.__version__, + 'settings': self.settings, + 'files': files, + } + with open(status_file, "w") as fout: + json.dump(status, fout) + + # Older versions of ShiningPanda look for the old name, status.dat. + # Accomodate them if we are running under Jenkins. + # https://issues.jenkins-ci.org/browse/JENKINS-28428 + if "JENKINS_URL" in os.environ: + with open(os.path.join(directory, "status.dat"), "w") as dat: + dat.write("https://issues.jenkins-ci.org/browse/JENKINS-28428\n") + + def settings_hash(self): + """Get the hash of the coverage.py settings.""" + return self.settings + + def set_settings_hash(self, settings): + """Set the hash of the coverage.py settings.""" + self.settings = settings + + def file_hash(self, fname): + """Get the hash of `fname`'s contents.""" + return self.files.get(fname, {}).get('hash', '') + + def set_file_hash(self, fname, val): + """Set the hash of `fname`'s contents.""" + self.files.setdefault(fname, {})['hash'] = val + + def index_info(self, fname): + """Get the information for index.html for `fname`.""" + return self.files.get(fname, {}).get('index', {}) + + def set_index_info(self, fname, info): + """Set the information for index.html for `fname`.""" + self.files.setdefault(fname, {})['index'] = info + + +# Helpers for templates and generating HTML + +def escape(t): + """HTML-escape the text in `t`. + + This is only suitable for HTML text, not attributes. + + """ + # Convert HTML special chars into HTML entities. + return t.replace("&", "&").replace("<", "<") + + +def pair(ratio): + """Format a pair of numbers so JavaScript can read them in an attribute.""" + return "%s %s" % ratio