| # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
| # 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, SimpleNamespace, format_local_datetime |
| from coverage.data import add_data_to_hash |
| from coverage.files import flat_rootname |
| 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 |
| |
| 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.""" |
| 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 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() == {""}: |
| 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 |
| # directory. |
| STATIC_FILES = [ |
| ("style.css", ""), |
| ("jquery.min.js", "jquery"), |
| ("jquery.ba-throttle-debounce.min.js", "jquery-throttle-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", ""), |
| ("favicon_32.png", ""), |
| ] |
| |
| def __init__(self, cov): |
| self.coverage = cov |
| self.config = self.coverage.config |
| self.directory = self.config.html_dir |
| |
| self.skip_covered = self.config.html_skip_covered |
| if self.skip_covered is None: |
| self.skip_covered = self.config.skip_covered |
| self.skip_empty = self.config.html_skip_empty |
| if self.skip_empty is None: |
| self.skip_empty= self.config.skip_empty |
| |
| 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, |
| 'len': len, |
| |
| # Constants for this report. |
| '__url__': coverage.__url__, |
| '__version__': coverage.__version__, |
| 'title': title, |
| 'time_stamp': format_local_datetime(datetime.datetime.now()), |
| 'extra_css': self.extra_css, |
| 'has_arcs': self.has_arcs, |
| 'show_contexts': self.config.show_contexts, |
| |
| # 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`. |
| |
| `morfs` is a list of modules or file names. |
| |
| """ |
| # 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. |
| 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() |
| |
| 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 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. |
| nums = analysis.numbers |
| self.all_files_nums.append(nums) |
| |
| if self.skip_covered: |
| # Don't report on 100% files. |
| no_missing_lines = (nums.n_missing == 0) |
| no_missing_branches = (nums.n_partial_branches == 0) |
| if no_missing_lines and no_missing_branches: |
| # If there's an existing file, remove it. |
| file_be_gone(html_path) |
| return |
| |
| if self.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. |
| if self.incr.can_skip_file(self.data, fr, rootname): |
| self.file_summaries.append(self.incr.index_info(rootname)) |
| return |
| |
| # 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 ldata.tokens: |
| if tok_type == "ws": |
| html.append(escape(tok_text)) |
| else: |
| tok_html = escape(tok_text) or ' ' |
| html.append( |
| u'<span class="{}">{}</span>'.format(tok_type, tok_html) |
| ) |
| ldata.html = ''.join(html) |
| |
| 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 |
| |
| 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. |
| index_info = { |
| 'nums': nums, |
| 'html_filename': html_filename, |
| 'relative_filename': fr.relative_filename(), |
| } |
| 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) |
| |
| html = index_tmpl.render({ |
| 'files': self.file_summaries, |
| 'totals': self.totals, |
| }) |
| |
| write_html(os.path.join(self.directory, "index.html"), html) |
| |
| # Write the latest hashes for next time. |
| self.incr.write() |
| |
| |
| class IncrementalChecker(object): |
| """Logic and data to support incremental reporting.""" |
| |
| STATUS_FILE = "status.json" |
| STATUS_FORMAT = 2 |
| |
| # pylint: disable=wrong-spelling-in-comment,useless-suppression |
| # The data looks like: |
| # |
| # { |
| # "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", |
| # "relative_filename": "cogapp/whiteutils", |
| # "nums": [ 1, 59, 0, 1, 28, 2, 2 ] |
| # } |
| # } |
| # } |
| # } |
| |
| def __init__(self, directory): |
| self.directory = directory |
| self.reset() |
| |
| def reset(self): |
| """Initialize to empty. Causes all files to be reported.""" |
| self.globals = '' |
| self.files = {} |
| |
| def read(self): |
| """Read the information we stored last time.""" |
| usable = False |
| try: |
| 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 |
| 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.globals = status['globals'] |
| else: |
| self.reset() |
| |
| 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() |
| files[filename] = fileinfo |
| |
| status = { |
| 'format': self.STATUS_FORMAT, |
| 'version': coverage.__version__, |
| 'globals': self.globals, |
| 'files': files, |
| } |
| with open(status_file, "w") as fout: |
| json.dump(status, fout, separators=(',', ':')) |
| |
| 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? |
| |
| `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() |
| |
| 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.""" |
| 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 |