eric6/DebugClients/Python/coverage/html.py

changeset 7427
362cd1b6f81a
parent 6942
2602857055c5
child 7702
f8b97639deb5
--- 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&#x202F;&#x219B;&#x202F;%s"
-                annotate_html = ",&nbsp;&nbsp; ".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 '&nbsp;'
                     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",&nbsp;&nbsp; ".join(
+                    u"{}&#x202F;&#x219B;&#x202F;{}".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."""

eric ide

mercurial