summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGES.txt4
-rw-r--r--coverage/backward.py7
-rw-r--r--coverage/data.py5
-rw-r--r--coverage/html.py105
-rw-r--r--coverage/htmlfiles/index.html2
-rw-r--r--coverage/misc.py39
-rw-r--r--coverage/report.py8
-rw-r--r--test/coveragetest.py22
-rw-r--r--test/test_html.py121
-rw-r--r--test/test_misc.py28
10 files changed, 332 insertions, 9 deletions
diff --git a/CHANGES.txt b/CHANGES.txt
index cd07ce78..bbf42f26 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -22,6 +22,10 @@ Version 3.5
- A little bit of Jython support: `coverage run` can now measure Jython
execution by adapting when $py.class files are traced. Thanks, Adi Roiban.
+- HTML reporting is now incremental: a record is kept of the data that
+ produced the HTML reports, and only files whose data has changed will
+ be generated. This should make most HTML reporting faster.
+
- Pathological code execution could disable the trace function behind our
backs, leading to incorrect code measurement. Now if this happens,
coverage.py will issue a warning, at least alerting you to the problem.
diff --git a/coverage/backward.py b/coverage/backward.py
index e901d84f..f0a34ac4 100644
--- a/coverage/backward.py
+++ b/coverage/backward.py
@@ -101,3 +101,10 @@ else:
"""Convert bytes `b` to a string (no-op in 2.x)."""
return b
+# Md5 is available in different places.
+try:
+ import hashlib
+ md5 = hashlib.md5
+except ImportError:
+ import md5
+ md5 = md5.new
diff --git a/coverage/data.py b/coverage/data.py
index 5d482eac..3263cb38 100644
--- a/coverage/data.py
+++ b/coverage/data.py
@@ -228,6 +228,11 @@ class CoverageData(object):
"""A map containing all the arcs executed in `filename`."""
return self.arcs.get(filename) or {}
+ def add_to_hash(self, filename, hasher):
+ """Contribute `filename`'s data to the Md5Hash `hasher`."""
+ hasher.update(self.executed_lines(filename))
+ hasher.update(self.executed_arcs(filename))
+
def summary(self, fullpath=False):
"""Return a dict summarizing the coverage data.
diff --git a/coverage/html.py b/coverage/html.py
index 87edad4d..966e10d9 100644
--- a/coverage/html.py
+++ b/coverage/html.py
@@ -3,7 +3,8 @@
import os, re, shutil
from coverage import __url__, __version__ # pylint: disable=W0611
-from coverage.misc import CoverageException
+from coverage.backward import pickle
+from coverage.misc import CoverageException, Hasher
from coverage.phystokens import source_token_lines
from coverage.report import Reporter
from coverage.templite import Templite
@@ -41,9 +42,11 @@ class HtmlReporter(Reporter):
super(HtmlReporter, self).__init__(coverage, ignore_errors)
self.directory = None
self.source_tmpl = Templite(data("htmlfiles/pyfile.html"), globals())
+ self.coverage = coverage
self.files = []
- self.arcs = coverage.data.has_arcs()
+ self.arcs = self.coverage.data.has_arcs()
+ self.status = HtmlStatus()
def report(self, morfs, config=None):
"""Generate an HTML report for `morfs`.
@@ -54,6 +57,17 @@ class HtmlReporter(Reporter):
"""
assert config.html_dir, "must provide a directory for html reporting"
+ # Read the status data.
+ self.status.read(config.html_dir)
+
+ # Check that this run used the same settings as the last run.
+ m = Hasher()
+ m.update(config)
+ these_settings = m.digest()
+ if self.status.settings_hash() != these_settings:
+ self.status.reset()
+ self.status.set_settings_hash(these_settings)
+
# Process all the files.
self.report_files(self.html_file, morfs, config, config.html_dir)
@@ -70,6 +84,13 @@ class HtmlReporter(Reporter):
os.path.join(self.directory, static)
)
+ def file_hash(self, source, cu):
+ """Compute a hash that changes if the file needs to be re-reported."""
+ m = Hasher()
+ m.update(source)
+ self.coverage.data.add_to_hash(cu.filename, m)
+ return m.digest()
+
def html_file(self, cu, analysis):
"""Generate an HTML file for one source file."""
source_file = cu.source_file()
@@ -78,6 +99,17 @@ class HtmlReporter(Reporter):
finally:
source_file.close()
+ # Find out if the file on disk is already correct.
+ flat_rootname = cu.flat_rootname()
+ this_hash = self.file_hash(source, cu)
+ that_hash = self.status.file_hash(flat_rootname)
+ if this_hash == that_hash:
+ # Nothing has changed to require the file to be reported again.
+ self.files.append(self.status.index_info(flat_rootname))
+ return
+
+ self.status.set_file_hash(flat_rootname, this_hash)
+
nums = analysis.numbers
missing_branch_arcs = analysis.missing_branch_arcs()
@@ -141,7 +173,7 @@ class HtmlReporter(Reporter):
})
# Write the HTML page for this file.
- html_filename = cu.flat_rootname() + ".html"
+ html_filename = flat_rootname + ".html"
html_path = os.path.join(self.directory, html_filename)
html = spaceless(self.source_tmpl.render(locals()))
fhtml = open(html_path, 'w')
@@ -151,12 +183,14 @@ class HtmlReporter(Reporter):
fhtml.close()
# Save this file's information for the index file.
- self.files.append({
+ index_info = {
'nums': nums,
'par': n_par,
'html_filename': html_filename,
- 'cu': cu,
- })
+ 'name': cu.name,
+ }
+ self.files.append(index_info)
+ self.status.set_index_info(flat_rootname, index_info)
def index_file(self):
"""Write the index.html file for this report."""
@@ -173,6 +207,65 @@ class HtmlReporter(Reporter):
finally:
fhtml.close()
+ # Write the latest hashes for next time.
+ self.status.write(self.directory)
+
+
+class HtmlStatus(object):
+ # A pickle of hashes for controlling deltas.
+ STATUS_FILE = "latest.dat"
+ STATUS_VERSION = 1
+
+ def __init__(self):
+ self.reset()
+
+ def reset(self):
+ self.settings = ''
+ self.status = {}
+
+ def read(self, directory):
+ self.reset()
+ try:
+ status_file = os.path.join(directory, self.STATUS_FILE)
+ data = pickle.load(open(status_file, "rb"))
+ except IOError:
+ pass
+ else:
+ if data['version'] == self.STATUS_VERSION:
+ self.status = data['files']
+ self.settings = data['settings']
+
+ def write(self, directory):
+ status_file = os.path.join(directory, self.STATUS_FILE)
+ data = {
+ 'version': self.STATUS_VERSION,
+ 'settings': self.settings,
+ 'files': self.status,
+ }
+ fout = open(status_file, "wb")
+ try:
+ pickle.dump(data, fout)
+ finally:
+ fout.close()
+
+ def settings_hash(self):
+ return self.settings
+
+ def set_settings_hash(self, settings):
+ self.settings = settings
+
+ def file_hash(self, fname):
+ return self.status.get(fname, {}).get('hash', '')
+
+ def set_file_hash(self, fname, val):
+ self.status.setdefault(fname, {})['hash'] = val
+
+ def index_info(self, fname):
+ return self.status.get(fname, {}).get('index', {})
+
+ def set_index_info(self, fname, info):
+ self.status.setdefault(fname, {})['index'] = info
+
# Helpers for templates and generating HTML
diff --git a/coverage/htmlfiles/index.html b/coverage/htmlfiles/index.html
index f03c325e..fb37d401 100644
--- a/coverage/htmlfiles/index.html
+++ b/coverage/htmlfiles/index.html
@@ -55,7 +55,7 @@
<tbody>
{% for file in files %}
<tr class='file'>
- <td class='name left'><a href='{{file.html_filename}}'>{{file.cu.name}}</a></td>
+ <td class='name left'><a href='{{file.html_filename}}'>{{file.name}}</a></td>
<td>{{file.nums.n_statements}}</td>
<td>{{file.nums.n_missing}}</td>
<td>{{file.nums.n_excluded}}</td>
diff --git a/coverage/misc.py b/coverage/misc.py
index 4218536d..3c02d3eb 100644
--- a/coverage/misc.py
+++ b/coverage/misc.py
@@ -1,5 +1,9 @@
"""Miscellaneous stuff for Coverage."""
+import inspect
+from coverage.backward import md5, string_class, to_bytes
+
+
def nice_pair(pair):
"""Make a nice string representation of a pair of numbers.
@@ -68,6 +72,41 @@ def bool_or_none(b):
return bool(b)
+class Hasher(object):
+ """Hashes Python data into md5."""
+ def __init__(self):
+ self.md5 = md5()
+
+ def update(self, v):
+ """Add `v` to the hash, recursively if needed."""
+ self.md5.update(to_bytes(str(type(v))))
+ if isinstance(v, string_class):
+ self.md5.update(to_bytes(v))
+ elif isinstance(v, (int, float)):
+ self.update(str(v))
+ elif isinstance(v, (tuple, list)):
+ for e in v:
+ self.update(e)
+ elif isinstance(v, dict):
+ keys = v.keys()
+ for k in sorted(keys):
+ self.update(k)
+ self.update(v[k])
+ else:
+ for k in dir(v):
+ if k.startswith('__'):
+ continue
+ a = getattr(v, k)
+ if inspect.isroutine(a):
+ continue
+ self.update(k)
+ self.update(a)
+
+ def digest(self):
+ """Retrieve the digest of the hash."""
+ return self.md5.digest()
+
+
class CoverageException(Exception):
"""An exception specific to Coverage."""
pass
diff --git a/coverage/report.py b/coverage/report.py
index 0fb353a2..5e2137f2 100644
--- a/coverage/report.py
+++ b/coverage/report.py
@@ -61,7 +61,13 @@ class Reporter(object):
def report_files(self, report_fn, morfs, config, directory=None):
"""Run a reporting function on a number of morfs.
- `report_fn` is called for each relative morf in `morfs`.
+ `report_fn` is called for each relative morf in `morfs`. It is called
+ as::
+
+ report_fn(code_unit, analysis)
+
+ where `code_unit` is the `CodeUnit` for the morf, and `analysis` is
+ the `Analysis` for the morf.
`config` is a CoverageConfig instance.
diff --git a/test/coveragetest.py b/test/coveragetest.py
index 621d7ae2..93cffa86 100644
--- a/test/coveragetest.py
+++ b/test/coveragetest.py
@@ -1,6 +1,6 @@
"""Base test case class for coverage testing."""
-import imp, os, random, shlex, shutil, sys, tempfile, textwrap
+import glob, imp, os, random, shlex, shutil, sys, tempfile, textwrap
import coverage
from coverage.backward import sorted, StringIO # pylint: disable=W0622
@@ -83,6 +83,9 @@ class CoverageTest(TestCase):
sys.stdout = self.old_stdout
sys.stderr = self.old_stderr
+ self.clean_modules()
+
+ def clean_modules(self):
# Remove any new modules imported during the test run. This lets us
# import the same source files for more than one test.
for m in [m for m in sys.modules if m not in self.old_modules]:
@@ -155,6 +158,23 @@ class CoverageTest(TestCase):
return filename
+ def clean_local_file_imports(self):
+ """Clean up the results of calls to `import_local_file`.
+
+ Use this if you need to `import_local_file` the same file twice in
+ one test.
+
+ """
+ # So that we can re-import files, clean them out first.
+ self.clean_modules()
+ # Also have to clean out the .pyc file, since the timestamp
+ # resolution is only one second, a changed file might not be
+ # picked up.
+ for pyc in glob.glob('*.pyc'):
+ os.remove(pyc)
+ if os.path.exists("__pycache__"):
+ shutil.rmtree("__pycache__")
+
def import_local_file(self, modname):
"""Import a local file as a module.
diff --git a/test/test_html.py b/test/test_html.py
new file mode 100644
index 00000000..ad03f054
--- /dev/null
+++ b/test/test_html.py
@@ -0,0 +1,121 @@
+"""Tests that HTML generation is awesome."""
+
+import os.path, sys
+import coverage
+sys.path.insert(0, os.path.split(__file__)[0]) # Force relative import for Py3k
+from coveragetest import CoverageTest
+
+class HtmlTest(CoverageTest):
+ """HTML!"""
+
+ def create_initial_files(self):
+ """Create the source files we need to run these tests."""
+ self.make_file("main_file.py", """\
+ import helper1, helper2
+ helper1.func1(12)
+ helper2.func2(12)
+ """)
+ self.make_file("helper1.py", """\
+ def func1(x):
+ if x % 2:
+ print("odd")
+ """)
+ self.make_file("helper2.py", """\
+ def func2(x):
+ print("x is %d" % x)
+ """)
+
+ def run_coverage(self, **kwargs):
+ """Run coverage on main_file.py, and create an HTML report."""
+ self.clean_local_file_imports()
+ cov = coverage.coverage(**kwargs)
+ cov.start()
+ self.import_local_file("main_file")
+ cov.stop()
+ cov.html_report()
+
+ def remove_html_files(self):
+ """Remove the HTML files created as part of the HTML report."""
+ os.remove("htmlcov/index.html")
+ os.remove("htmlcov/main_file.html")
+ os.remove("htmlcov/helper1.html")
+ os.remove("htmlcov/helper2.html")
+
+ def test_html_created(self):
+ # Test basic HTML generation: files should be created.
+ self.create_initial_files()
+ self.run_coverage()
+
+ self.assert_exists("htmlcov/index.html")
+ self.assert_exists("htmlcov/main_file.html")
+ self.assert_exists("htmlcov/helper1.html")
+ self.assert_exists("htmlcov/helper2.html")
+ self.assert_exists("htmlcov/style.css")
+ self.assert_exists("htmlcov/coverage_html.js")
+
+ def test_html_delta_from_source_change(self):
+ # HTML generation can create only the files that have changed.
+ # In this case, helper1 changes because its source is different.
+ self.create_initial_files()
+ self.run_coverage()
+ index1 = open("htmlcov/index.html").read()
+ self.remove_html_files()
+
+ # Now change a file and do it again
+ self.make_file("helper1.py", """\
+ def func1(x): # A nice function
+ if x % 2:
+ print("odd")
+ """)
+
+ self.run_coverage()
+
+ # Only the changed files should have been created.
+ self.assert_exists("htmlcov/index.html")
+ self.assert_exists("htmlcov/helper1.html")
+ self.assert_doesnt_exist("htmlcov/main_file.html")
+ self.assert_doesnt_exist("htmlcov/helper2.html")
+ index2 = open("htmlcov/index.html").read()
+ self.assertMultiLineEqual(index1, index2)
+
+ def test_html_delta_from_coverage_change(self):
+ # HTML generation can create only the files that have changed.
+ # In this case, helper1 changes because its coverage is different.
+ self.create_initial_files()
+ self.run_coverage()
+ self.remove_html_files()
+
+ # Now change a file and do it again
+ self.make_file("main_file.py", """\
+ import helper1, helper2
+ helper1.func1(23)
+ helper2.func2(23)
+ """)
+
+ self.run_coverage()
+
+ # Only the changed files should have been created.
+ self.assert_exists("htmlcov/index.html")
+ self.assert_exists("htmlcov/helper1.html")
+ self.assert_exists("htmlcov/main_file.html")
+ self.assert_doesnt_exist("htmlcov/helper2.html")
+
+ def test_html_delta_from_settings_change(self):
+ # HTML generation can create only the files that have changed.
+ # In this case, everything changes because the coverage settings have
+ # changed.
+ self.create_initial_files()
+ self.run_coverage()
+ index1 = open("htmlcov/index.html").read()
+ self.remove_html_files()
+
+ self.run_coverage(timid=True)
+
+ # Only the changed files should have been created.
+ self.assert_exists("htmlcov/index.html")
+ self.assert_exists("htmlcov/helper1.html")
+ self.assert_exists("htmlcov/main_file.html")
+ self.assert_exists("htmlcov/helper2.html")
+ index2 = open("htmlcov/index.html").read()
+ self.assertMultiLineEqual(index1, index2)
+
diff --git a/test/test_misc.py b/test/test_misc.py
new file mode 100644
index 00000000..72f5caac
--- /dev/null
+++ b/test/test_misc.py
@@ -0,0 +1,28 @@
+"""Tests of miscellaneous stuff."""
+
+import os, sys
+
+from coverage.misc import Hasher
+sys.path.insert(0, os.path.split(__file__)[0]) # Force relative import for Py3k
+from coveragetest import CoverageTest
+
+class HasherTest(CoverageTest):
+ """Test our wrapper of md5 hashing."""
+
+ def test_string_hashing(self):
+ h1 = Hasher()
+ h1.update("Hello, world!")
+ h2 = Hasher()
+ h2.update("Goodbye!")
+ h3 = Hasher()
+ h3.update("Hello, world!")
+ self.assertNotEqual(h1.digest(), h2.digest())
+ self.assertEqual(h1.digest(), h3.digest())
+
+ def test_dict_hashing(self):
+ h1 = Hasher()
+ h1.update({'a': 17, 'b': 23})
+ h2 = Hasher()
+ h2.update({'b': 23, 'a': 17})
+ self.assertEqual(h1.digest(), h2.digest())
+