diff options
-rw-r--r-- | CHANGES.txt | 4 | ||||
-rw-r--r-- | coverage/backward.py | 7 | ||||
-rw-r--r-- | coverage/data.py | 5 | ||||
-rw-r--r-- | coverage/html.py | 105 | ||||
-rw-r--r-- | coverage/htmlfiles/index.html | 2 | ||||
-rw-r--r-- | coverage/misc.py | 39 | ||||
-rw-r--r-- | coverage/report.py | 8 | ||||
-rw-r--r-- | test/coveragetest.py | 22 | ||||
-rw-r--r-- | test/test_html.py | 121 | ||||
-rw-r--r-- | test/test_misc.py | 28 |
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()) + |