diff -r dc171b1d8261 -r 362cd1b6f81a eric6/DebugClients/Python/coverage/html.py --- a/eric6/DebugClients/Python/coverage/html.py Wed Feb 19 19:38:36 2020 +0100 +++ b/eric6/DebugClients/Python/coverage/html.py Sat Feb 22 14:27:42 2020 +0100 @@ -1,19 +1,21 @@ # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 -# For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt +# For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt """HTML reporting for coverage.py.""" import datetime import json import os +import re import shutil import coverage from coverage import env -from coverage.backward import iitems +from coverage.backward import iitems, SimpleNamespace +from coverage.data import add_data_to_hash from coverage.files import flat_rootname -from coverage.misc import CoverageException, file_be_gone, Hasher, isolate_module -from coverage.report import Reporter +from coverage.misc import CoverageException, ensure_dir, file_be_gone, Hasher, isolate_module +from coverage.report import get_analysis_to_report from coverage.results import Numbers from coverage.templite import Templite @@ -66,11 +68,90 @@ def write_html(fname, html): """Write `html` to `fname`, properly encoded.""" + html = re.sub(r"(\A\s+)|(\s+$)", "", html, flags=re.MULTILINE) + "\n" with open(fname, "wb") as fout: fout.write(html.encode('ascii', 'xmlcharrefreplace')) -class HtmlReporter(Reporter): +class HtmlDataGeneration(object): + """Generate structured data to be turned into HTML reports.""" + + EMPTY = "(empty)" + + def __init__(self, cov): + self.coverage = cov + self.config = self.coverage.config + data = self.coverage.get_data() + self.has_arcs = data.has_arcs() + if self.config.show_contexts: + if data.measured_contexts() == set([""]): + self.coverage._warn("No contexts were measured") + data.set_query_contexts(self.config.report_contexts) + + def data_for_file(self, fr, analysis): + """Produce the data needed for one file's report.""" + if self.has_arcs: + missing_branch_arcs = analysis.missing_branch_arcs() + arcs_executed = analysis.arcs_executed() + + if self.config.show_contexts: + contexts_by_lineno = analysis.data.contexts_by_lineno(analysis.filename) + + lines = [] + + for lineno, tokens in enumerate(fr.source_token_lines(), start=1): + # Figure out how to mark this line. + category = None + short_annotations = [] + long_annotations = [] + + if lineno in analysis.excluded: + category = 'exc' + elif lineno in analysis.missing: + category = 'mis' + elif self.has_arcs and lineno in missing_branch_arcs: + category = 'par' + for b in missing_branch_arcs[lineno]: + if b < 0: + short_annotations.append("exit") + else: + short_annotations.append(b) + long_annotations.append(fr.missing_arc_description(lineno, b, arcs_executed)) + elif lineno in analysis.statements: + category = 'run' + + contexts = contexts_label = None + context_list = None + if category and self.config.show_contexts: + contexts = sorted(c or self.EMPTY for c in contexts_by_lineno[lineno]) + if contexts == [self.EMPTY]: + contexts_label = self.EMPTY + else: + contexts_label = "{} ctx".format(len(contexts)) + context_list = contexts + + lines.append(SimpleNamespace( + tokens=tokens, + number=lineno, + category=category, + statement=(lineno in analysis.statements), + contexts=contexts, + contexts_label=contexts_label, + context_list=context_list, + short_annotations=short_annotations, + long_annotations=long_annotations, + )) + + file_data = SimpleNamespace( + relative_filename=fr.relative_filename(), + nums=analysis.numbers, + lines=lines, + ) + + return file_data + + +class HtmlReporter(object): """HTML reporting.""" # These files will be copied from the htmlfiles directory to the output @@ -87,30 +168,54 @@ ("keybd_open.png", ""), ] - def __init__(self, cov, config): - super(HtmlReporter, self).__init__(cov, config) - self.directory = None + def __init__(self, cov): + self.coverage = cov + self.config = self.coverage.config + self.directory = self.config.html_dir title = self.config.html_title if env.PY2: title = title.decode("utf8") + + if self.config.extra_css: + self.extra_css = os.path.basename(self.config.extra_css) + else: + self.extra_css = None + + self.data = self.coverage.get_data() + self.has_arcs = self.data.has_arcs() + + self.file_summaries = [] + self.all_files_nums = [] + self.incr = IncrementalChecker(self.directory) + self.datagen = HtmlDataGeneration(self.coverage) + self.totals = Numbers() + self.template_globals = { + # Functions available in the templates. 'escape': escape, 'pair': pair, - 'title': title, + 'len': len, + + # Constants for this report. '__url__': coverage.__url__, '__version__': coverage.__version__, - } - self.source_tmpl = Templite(read_data("pyfile.html"), self.template_globals) - - self.coverage = cov + 'title': title, + 'time_stamp': datetime.datetime.now().strftime('%Y-%m-%d %H:%M'), + 'extra_css': self.extra_css, + 'has_arcs': self.has_arcs, + 'show_contexts': self.config.show_contexts, - self.files = [] - self.all_files_nums = [] - 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') + # Constants for all reports. + # These css classes determine which lines are highlighted by default. + 'category': { + 'exc': 'exc show_exc', + 'mis': 'mis show_mis', + 'par': 'par run show_par', + 'run': 'run', + } + } + self.pyfile_html_source = read_data("pyfile.html") + self.source_tmpl = Templite(self.pyfile_html_source, self.template_globals) def report(self, morfs): """Generate an HTML report for `morfs`. @@ -118,29 +223,20 @@ `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) + # Read the status data and check that this run used the same + # global data as the last run. + self.incr.read() + self.incr.check_global_data(self.config, self.pyfile_html_source) # Process all the files. - self.report_files(self.html_file, morfs, self.config.html_dir) + for fr, analysis in get_analysis_to_report(self.coverage, morfs): + self.html_file(fr, analysis) if not self.all_files_nums: raise CoverageException("No data to report.") + self.totals = sum(self.all_files_nums) + # Write the index file. self.index_file() @@ -163,17 +259,11 @@ 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.""" rootname = flat_rootname(fr.relative_filename()) html_filename = rootname + ".html" + ensure_dir(self.directory) html_path = os.path.join(self.directory, html_filename) # Get the numbers for this file. @@ -189,100 +279,63 @@ file_be_gone(html_path) return - source = fr.source() + if self.config.skip_empty: + # Don't report on empty files. + if nums.n_statements == 0: + file_be_gone(html_path) + return # Find out if the file on disk is already correct. - 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)) + if self.incr.can_skip_file(self.data, fr, rootname): + self.file_summaries.append(self.incr.index_info(rootname)) return - self.status.set_file_hash(rootname, this_hash) - - 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) - + # Write the HTML page for this file. + file_data = self.datagen.data_for_file(fr, analysis) + for ldata in file_data.lines: # Build the HTML for the line. html = [] - for tok_type, tok_text in line: + for tok_type, tok_text in ldata.tokens: 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) + u'<span class="{}">{}</span>'.format(tok_type, tok_html) ) + ldata.html = ''.join(html) - lines.append({ - 'html': ''.join(html), - 'number': lineno, - 'class': ' '.join(line_class) or "pln", - 'annotate': annotate_html, - 'annotate_long': annotate_long, - }) + if ldata.short_annotations: + # 202F is NARROW NO-BREAK SPACE. + # 219B is RIGHTWARDS ARROW WITH STROKE. + ldata.annotate = u", ".join( + u"{} ↛ {}".format(ldata.number, d) + for d in ldata.short_annotations + ) + else: + ldata.annotate = None - # 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, - }) + if ldata.long_annotations: + longs = ldata.long_annotations + if len(longs) == 1: + ldata.annotate_long = longs[0] + else: + ldata.annotate_long = u"{:d} missed branches: {}".format( + len(longs), + u", ".join( + u"{:d}) {}".format(num, ann_long) + for num, ann_long in enumerate(longs, start=1) + ), + ) + else: + ldata.annotate_long = None + css_classes = [] + if ldata.category: + css_classes.append(self.template_globals['category'][ldata.category]) + ldata.css_class = ' '.join(css_classes) or "pln" + + html = self.source_tmpl.render(file_data.__dict__) write_html(html_path, html) # Save this file's information for the index file. @@ -291,77 +344,73 @@ 'html_filename': html_filename, 'relative_filename': fr.relative_filename(), } - self.files.append(index_info) - self.status.set_index_info(rootname, index_info) + self.file_summaries.append(index_info) + self.incr.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(self.all_files_nums) - html = index_tmpl.render({ - 'has_arcs': self.has_arcs, - 'extra_css': self.extra_css, - 'files': self.files, + 'files': self.file_summaries, '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) + self.incr.write() -class HtmlStatus(object): - """The status information we keep to support incremental reporting.""" +class IncrementalChecker(object): + """Logic and data to support incremental reporting.""" STATUS_FILE = "status.json" - STATUS_FORMAT = 1 + STATUS_FORMAT = 2 # 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>, + # "format": 2, + # "globals": "540ee119c15d52a68a53fe6f0897346d", + # "version": "4.0a1", + # "files": { + # "cogapp___init__": { + # "hash": "e45581a5b48f879f301c0f30bf77a50c", + # "index": { + # "html_filename": "cogapp___init__.html", + # "relative_filename": "cogapp/__init__", + # "nums": [ 1, 14, 0, 0, 0, 0, 0 ] # } # }, # ... - # 'cogapp_whiteutils': { - # 'hash': '8504bb427fc488c4176809ded0277d51', - # 'index': { - # 'html_filename': 'cogapp_whiteutils.html', - # 'name': 'cogapp/whiteutils', - # 'nums': <coverage.results.Numbers object at 0x10ab7d90>, + # "cogapp_whiteutils": { + # "hash": "8504bb427fc488c4176809ded0277d51", + # "index": { + # "html_filename": "cogapp_whiteutils.html", + # "relative_filename": "cogapp/whiteutils", + # "nums": [ 1, 59, 0, 1, 28, 2, 2 ] # } - # }, - # }, + # } + # } # } - def __init__(self): + def __init__(self, directory): + self.directory = directory self.reset() def reset(self): - """Initialize to empty.""" - self.settings = '' + """Initialize to empty. Causes all files to be reported.""" + self.globals = '' self.files = {} - def read(self, directory): - """Read the last status in `directory`.""" + def read(self): + """Read the information we stored last time.""" usable = False try: - status_file = os.path.join(directory, self.STATUS_FILE) - with open(status_file, "r") as fstatus: + status_file = os.path.join(self.directory, self.STATUS_FILE) + with open(status_file) as fstatus: status = json.load(fstatus) except (IOError, ValueError): usable = False @@ -377,13 +426,13 @@ for filename, fileinfo in iitems(status['files']): fileinfo['index']['nums'] = Numbers(*fileinfo['index']['nums']) self.files[filename] = fileinfo - self.settings = status['settings'] + self.globals = status['globals'] else: self.reset() - def write(self, directory): - """Write the current status to `directory`.""" - status_file = os.path.join(directory, self.STATUS_FILE) + def write(self): + """Write the current status.""" + status_file = os.path.join(self.directory, self.STATUS_FILE) files = {} for filename, fileinfo in iitems(self.files): fileinfo['index']['nums'] = fileinfo['index']['nums'].init_args() @@ -392,26 +441,41 @@ status = { 'format': self.STATUS_FORMAT, 'version': coverage.__version__, - 'settings': self.settings, + 'globals': self.globals, 'files': files, } with open(status_file, "w") as fout: json.dump(status, fout, separators=(',', ':')) - # Older versions of ShiningPanda look for the old name, status.dat. - # Accommodate 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 check_global_data(self, *data): + """Check the global data that can affect incremental reporting.""" + m = Hasher() + for d in data: + m.update(d) + these_globals = m.hexdigest() + if self.globals != these_globals: + self.reset() + self.globals = these_globals + + def can_skip_file(self, data, fr, rootname): + """Can we skip reporting this file? - def settings_hash(self): - """Get the hash of the coverage.py settings.""" - return self.settings + `data` is a CoverageData object, `fr` is a `FileReporter`, and + `rootname` is the name being used for the file. + """ + m = Hasher() + m.update(fr.source().encode('utf-8')) + add_data_to_hash(data, fr.filename, m) + this_hash = m.hexdigest() - def set_settings_hash(self, settings): - """Set the hash of the coverage.py settings.""" - self.settings = settings + that_hash = self.file_hash(rootname) + + if this_hash == that_hash: + # Nothing has changed to require the file to be reported again. + return True + else: + self.set_file_hash(rootname, this_hash) + return False def file_hash(self, fname): """Get the hash of `fname`'s contents."""