diff options
-rw-r--r-- | AUTHORS.txt | 2 | ||||
-rw-r--r-- | CHANGES.txt | 10 | ||||
-rw-r--r-- | coverage/html.py | 4 | ||||
-rw-r--r-- | coverage/htmlfiles/index.html | 3 | ||||
-rw-r--r-- | coverage/htmlfiles/pyfile.html | 3 | ||||
-rw-r--r-- | coverage/parser.py | 37 | ||||
-rw-r--r-- | coverage/pytracer.py | 24 | ||||
-rw-r--r-- | coverage/results.py | 3 | ||||
-rw-r--r-- | coverage/tracer.c | 18 | ||||
-rw-r--r-- | lab/parser.py | 18 | ||||
-rw-r--r-- | lab/run_trace.py | 32 | ||||
-rw-r--r-- | lab/sample.py | 5 | ||||
-rw-r--r-- | lab/trace_sample.py | 57 | ||||
-rw-r--r-- | tests/test_arcs.py | 108 | ||||
-rw-r--r-- | tests/test_html.py | 76 | ||||
-rw-r--r-- | tests/test_parser.py | 16 |
16 files changed, 300 insertions, 116 deletions
diff --git a/AUTHORS.txt b/AUTHORS.txt index 2b84a1b..b0ea69f 100644 --- a/AUTHORS.txt +++ b/AUTHORS.txt @@ -18,6 +18,7 @@ Chris Rose Christian Heimes Christine Lytwynec Christoph Zwerschke +Conrad Ho Danek Duvall Danny Allen David Christian @@ -44,6 +45,7 @@ Marcus Cobden Mark van der Wal Martin Fuzzey Matthew Desmarais +Mickie Betz Noel O'Boyle Pablo Carballo Patrick Mezard diff --git a/CHANGES.txt b/CHANGES.txt index cbe4d56..1871b3b 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -9,15 +9,25 @@ Latest - Branch coverage couldn't properly handle certain extremely long files. This is now fixed (`issue 359`_). +- Branch coverage didn't understand yield statements properly. Mickie Betz + persisted in pursuing this despite Ned's pessimism. Fixes `issue 308`_ and + `issue 324`_. + - HTML reports were truncated at formfeed characters. This is now fixed (`issue 360`_). It's always fun when the problem is due to a `bug in the Python standard library <http://bugs.python.org/issue19035>`_. +- HTML reports now include a timestamp in the footer, closing `issue 299`_. + Thanks, Conrad Ho. + - HTML reports now begrudgingly use double-quotes rather than single quotes, because there are "software engineers" out there writing tools that read HTML and somehow have no idea that single quotes exist. Fixes `issue 361`_. Thanks, Jon Chappell. +.. _issue 299: https://bitbucket.org/ned/coveragepy/issue/299/inserted-created-on-yyyy-mm-dd-hh-mm-in +.. _issue 308: https://bitbucket.org/ned/coveragepy/issue/308/yield-lambda-branch-coverage +.. _issue 324: https://bitbucket.org/ned/coveragepy/issue/324/yield-in-loop-confuses-branch-coverage .. _issue 359: https://bitbucket.org/ned/coveragepy/issue/359/xml-report-chunk-error .. _issue 360: https://bitbucket.org/ned/coveragepy/issue/360/html-reports-get-confused-by-l-in-the-code .. _issue 361: https://bitbucket.org/ned/coveragepy/issue/361/use-double-quotes-in-html-output-to diff --git a/coverage/html.py b/coverage/html.py index 8ed085b..0b2cc25 100644 --- a/coverage/html.py +++ b/coverage/html.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals +import datetime import json import os import re @@ -92,6 +93,7 @@ class HtmlReporter(Reporter): self.status = HtmlStatus() self.extra_css = None self.totals = Numbers() + self.time_stamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M') def report(self, morfs): """Generate an HTML report for `morfs`. @@ -237,6 +239,7 @@ class HtmlReporter(Reporter): 'c_exc': c_exc, 'c_mis': c_mis, 'c_par': c_par, 'c_run': c_run, 'arcs': self.arcs, 'extra_css': self.extra_css, 'fr': fr, 'nums': nums, 'lines': lines, + 'time_stamp': self.time_stamp, } html = spaceless(self.source_tmpl.render(template_values)) @@ -266,6 +269,7 @@ class HtmlReporter(Reporter): 'extra_css': self.extra_css, 'files': self.files, 'totals': self.totals, + 'time_stamp': self.time_stamp, }) self.write_html( diff --git a/coverage/htmlfiles/index.html b/coverage/htmlfiles/index.html index 5242d32..1afc57c 100644 --- a/coverage/htmlfiles/index.html +++ b/coverage/htmlfiles/index.html @@ -105,7 +105,8 @@ <div id="footer"> <div class="content"> <p> - <a class="nav" href="{{__url__}}">coverage.py v{{__version__}}</a> + <a class="nav" href="{{__url__}}">coverage.py v{{__version__}}</a>, + created at {{ time_stamp }} </p> </div> </div> diff --git a/coverage/htmlfiles/pyfile.html b/coverage/htmlfiles/pyfile.html index 72b6928..d78ba53 100644 --- a/coverage/htmlfiles/pyfile.html +++ b/coverage/htmlfiles/pyfile.html @@ -84,7 +84,8 @@ <div id="footer"> <div class="content"> <p> - <a class="nav" href="index.html">« index</a> <a class="nav" href="{{__url__}}">coverage.py v{{__version__}}</a> + <a class="nav" href="index.html">« index</a> <a class="nav" href="{{__url__}}">coverage.py v{{__version__}}</a>, + created at {{ time_stamp }} </p> </div> </div> diff --git a/coverage/parser.py b/coverage/parser.py index f488367..fc751eb 100644 --- a/coverage/parser.py +++ b/coverage/parser.py @@ -460,7 +460,7 @@ class ByteParser(object): # Walk the byte codes building chunks. for bc in bytecodes: - # Maybe have to start a new chunk + # Maybe have to start a new chunk. start_new_chunk = False first_chunk = False if bc.offset in bytes_lines_map: @@ -481,9 +481,13 @@ class ByteParser(object): if chunk: chunk.exits.add(bc.offset) chunk = Chunk(bc.offset, chunk_lineno, first_chunk) + if not chunks: + # The very first chunk of a code object is always an + # entrance. + chunk.entrance = True chunks.append(chunk) - # Look at the opcode + # Look at the opcode. if bc.jump_to >= 0 and bc.op not in OPS_NO_JUMP: if ignore_branch: # Someone earlier wanted us to ignore this branch. @@ -570,15 +574,15 @@ class ByteParser(object): """ chunks = self._split_into_chunks() - # A map from byte offsets to chunks jumped into. + # A map from byte offsets to the chunk starting at that offset. byte_chunks = dict((c.byte, c) for c in chunks) - # There's always an entrance at the first chunk. - yield (-1, byte_chunks[0].line) - # Traverse from the first chunk in each line, and yield arcs where # the trace function will be invoked. for chunk in chunks: + if chunk.entrance: + yield (-1, chunk.line) + if not chunk.first: continue @@ -586,7 +590,7 @@ class ByteParser(object): chunks_to_consider = [chunk] while chunks_to_consider: # Get the chunk we're considering, and make sure we don't - # consider it again + # consider it again. this_chunk = chunks_to_consider.pop() chunks_considered.add(this_chunk) @@ -649,6 +653,8 @@ class Chunk(object): .. _basic block: http://en.wikipedia.org/wiki/Basic_block + `byte` is the offset to the bytecode starting this chunk. + `line` is the source line number containing this chunk. `first` is true if this is the first chunk in the source line. @@ -656,19 +662,24 @@ class Chunk(object): An exit < 0 means the chunk can leave the code (return). The exit is the negative of the starting line number of the code block. + The `entrance` attribute is a boolean indicating whether the code object + can be entered at this chunk. + """ def __init__(self, byte, line, first): self.byte = byte self.line = line self.first = first self.length = 0 + self.entrance = False self.exits = set() def __repr__(self): - if self.first: - bang = "!" - else: - bang = "" - return "<%d+%d @%d%s %r>" % ( - self.byte, self.length, self.line, bang, list(self.exits) + return "<%d+%d @%d%s%s %r>" % ( + self.byte, + self.length, + self.line, + "!" if self.first else "", + "v" if self.entrance else "", + list(self.exits), ) diff --git a/coverage/pytracer.py b/coverage/pytracer.py index 0eafbef..3f03aaf 100644 --- a/coverage/pytracer.py +++ b/coverage/pytracer.py @@ -1,7 +1,15 @@ """Raw data collector for Coverage.""" +import dis import sys +from coverage import env + +# We need the YIELD_VALUE opcode below, in a comparison-friendly form. +YIELD_VALUE = dis.opmap['YIELD_VALUE'] +if env.PY2: + YIELD_VALUE = chr(YIELD_VALUE) + class PyTracer(object): """Python implementation of the raw data tracer.""" @@ -79,9 +87,11 @@ class PyTracer(object): if tracename not in self.data: self.data[tracename] = {} self.cur_file_dict = self.data[tracename] - # Set the last_line to -1 because the next arc will be entering a - # code block, indicated by (-1, n). - self.last_line = -1 + # The call event is really a "start frame" event, and happens for + # function calls and re-entering generators. The f_lasti field is + # -1 for calls, and a real offset for generators. Use -1 as the + # line number for calls, and the real line number for generators. + self.last_line = -1 if (frame.f_lasti < 0) else frame.f_lineno elif event == 'line': # Record an executed line. if self.cur_file_dict is not None: @@ -93,8 +103,12 @@ class PyTracer(object): self.last_line = lineno elif event == 'return': if self.arcs and self.cur_file_dict: - first = frame.f_code.co_firstlineno - self.cur_file_dict[(self.last_line, -first)] = None + # Record an arc leaving the function, but beware that a + # "return" event might just mean yielding from a generator. + bytecode = frame.f_code.co_code[frame.f_lasti] + if bytecode != YIELD_VALUE: + first = frame.f_code.co_firstlineno + self.cur_file_dict[(self.last_line, -first)] = None # Leaving this function, pop the filename stack. self.cur_file_dict, self.last_line = self.data_stack.pop() elif event == 'exception': diff --git a/coverage/results.py b/coverage/results.py index c1718d4..7b621c1 100644 --- a/coverage/results.py +++ b/coverage/results.py @@ -101,10 +101,13 @@ class Analysis(object): # Exclude arcs here which connect a line to itself. They can occur # in executed data in some cases. This is where they can cause # trouble, and here is where it's the least burden to remove them. + # Also, generators can somehow cause arcs from "enter" to "exit", so + # make sure we have at least one positive value. unpredicted = ( e for e in executed if e not in possible and e[0] != e[1] + and (e[0] > 0 or e[1] > 0) ) return sorted(unpredicted) diff --git a/coverage/tracer.c b/coverage/tracer.c index 52543b8..fe40fc6 100644 --- a/coverage/tracer.c +++ b/coverage/tracer.c @@ -3,6 +3,7 @@ #include "Python.h" #include "structmember.h" #include "frameobject.h" +#include "opcode.h" /* Compile-time debugging helpers */ #undef WHAT_LOG /* Define to log the WHAT params in the trace function. */ @@ -614,7 +615,12 @@ CTracer_handle_call(CTracer *self, PyFrameObject *frame) } self->cur_entry.disposition = disposition; - self->cur_entry.last_line = -1; + + /* A call event is really a "start frame" event, and can happen for + * re-entering a generator also. f_lasti is -1 for a true call, and a + * real byte offset for a generator re-entry. + */ + self->cur_entry.last_line = (frame->f_lasti < 0) ? -1 : frame->f_lineno; ok: ret = RET_OK; @@ -808,9 +814,13 @@ CTracer_handle_return(CTracer *self, PyFrameObject *frame) } if (self->pdata_stack->depth >= 0) { if (self->tracing_arcs && self->cur_entry.file_data) { - int first = frame->f_code->co_firstlineno; - if (CTracer_record_pair(self, self->cur_entry.last_line, -first) < 0) { - goto error; + /* Need to distinguish between RETURN_VALUE and YIELD_VALUE. */ + int bytecode = MyBytes_AS_STRING(frame->f_code->co_code)[frame->f_lasti]; + if (bytecode != YIELD_VALUE) { + int first = frame->f_code->co_firstlineno; + if (CTracer_record_pair(self, self->cur_entry.last_line, -first) < 0) { + goto error; + } } } diff --git a/lab/parser.py b/lab/parser.py index 1783468..662183a 100644 --- a/lab/parser.py +++ b/lab/parser.py @@ -9,8 +9,8 @@ from optparse import OptionParser import disgen from coverage.misc import CoverageException -from coverage.files import get_python_source from coverage.parser import ByteParser, PythonParser +from coverage.python import get_python_source opcode_counts = collections.Counter() @@ -82,7 +82,7 @@ class ParserMain(object): self.disassemble(bp, histogram=options.histogram) arcs = bp._all_arcs() - if options.chunks and not options.dis: + if options.chunks:# and not options.dis: chunks = bp._all_chunks() if options.recursive: print("%6d: %s" % (len(chunks), filename)) @@ -116,7 +116,7 @@ class ParserMain(object): m2 = 'C' if lineno in cp.excluded: m3 = 'x' - a = arc_chars.get(lineno, '').ljust(arc_width) + a = arc_chars[lineno].ljust(arc_width) print("%4d %s%s%s%s%s %s" % (lineno, m0, m1, m2, m3, a, ltext) ) @@ -162,12 +162,12 @@ class ParserMain(object): dictionary mapping line numbers to ascii strings to draw for that line. """ - arc_chars = {} + arc_chars = collections.defaultdict(str) for lfrom, lto in sorted(arcs): if lfrom < 0: - arc_chars[lto] = arc_chars.get(lto, '') + 'v' + arc_chars[lto] += 'v' elif lto < 0: - arc_chars[lfrom] = arc_chars.get(lfrom, '') + '^' + arc_chars[lfrom] += '^' else: if lfrom == lto - 1: # Don't show obvious arcs. @@ -176,7 +176,7 @@ class ParserMain(object): l1, l2 = lfrom, lto else: l1, l2 = lto, lfrom - w = max([len(arc_chars.get(l, '')) for l in range(l1, l2+1)]) + w = max(len(arc_chars[l]) for l in range(l1, l2+1)) for l in range(l1, l2+1): if l == lfrom: ch = '<' @@ -184,11 +184,11 @@ class ParserMain(object): ch = '>' else: ch = '|' - arc_chars[l] = arc_chars.get(l, '').ljust(w) + ch + arc_chars[l] = arc_chars[l].ljust(w) + ch arc_width = 0 if arc_chars: - arc_width = max([len(a) for a in arc_chars.values()]) + arc_width = max(len(a) for a in arc_chars.values()) else: arc_width = 0 diff --git a/lab/run_trace.py b/lab/run_trace.py new file mode 100644 index 0000000..3822a80 --- /dev/null +++ b/lab/run_trace.py @@ -0,0 +1,32 @@ +"""Run a simple trace function on a file of Python code.""" + +import os, sys + +nest = 0 + +def trace(frame, event, arg): + global nest + + if nest is None: + # This can happen when Python is shutting down. + return None + + print "%s%s %s %d @%d" % ( + " " * nest, + event, + os.path.basename(frame.f_code.co_filename), + frame.f_lineno, + frame.f_lasti, + ) + + if event == 'call': + nest += 1 + if event == 'return': + nest -= 1 + + return trace + +the_program = sys.argv[1] + +sys.settrace(trace) +execfile(the_program) diff --git a/lab/sample.py b/lab/sample.py deleted file mode 100644 index bb62848..0000000 --- a/lab/sample.py +++ /dev/null @@ -1,5 +0,0 @@ -a, b = 1, 0 -if a or b or fn(): - # Hey - a = 3 -d = 4 diff --git a/lab/trace_sample.py b/lab/trace_sample.py deleted file mode 100644 index 3f81919..0000000 --- a/lab/trace_sample.py +++ /dev/null @@ -1,57 +0,0 @@ -import os, sys - -global nest -nest = 0 - -def trace(frame, event, arg): - #if event == 'line': - global nest - - print "%s%s %s %d" % ( - " " * nest, - event, - os.path.basename(frame.f_code.co_filename), - frame.f_lineno, - ) - - if event == 'call': - nest += 1 - if event == 'return': - nest -= 1 - - return trace - -def trace2(frame, event, arg): - #if event == 'line': - global nest - - print "2: %s%s %s %d" % ( - " " * nest, - event, - os.path.basename(frame.f_code.co_filename), - frame.f_lineno, - ) - - if event == 'call': - nest += 1 - if event == 'return': - nest -= 1 - - return trace2 - -sys.settrace(trace) - -def bar(): - print "nar" - -a = 26 -def foo(n): - a = 28 - sys.settrace(sys.gettrace()) - bar() - a = 30 - return 2*n - -print foo(a) -#import sample -#import littleclass diff --git a/tests/test_arcs.py b/tests/test_arcs.py index 81fa7e6..a4462ea 100644 --- a/tests/test_arcs.py +++ b/tests/test_arcs.py @@ -560,6 +560,114 @@ class ExceptionArcTest(CoverageTest): arcz_missing="67 7B", arcz_unpredicted="68") +class YieldTest(CoverageTest): + """Arc tests for generators.""" + + def test_yield_in_loop(self): + self.check_coverage("""\ + def gen(inp): + for n in inp: + yield n + + list(gen([1,2,3])) + """, + arcz=".1 .2 23 2. 32 15 5.", + arcz_missing="", + arcz_unpredicted="") + + def test_padded_yield_in_loop(self): + self.check_coverage("""\ + def gen(inp): + i = 2 + for n in inp: + i = 4 + yield n + i = 6 + i = 7 + + list(gen([1,2,3])) + """, + arcz=".1 19 9. .2 23 34 45 56 63 37 7.", + arcz_missing="", + arcz_unpredicted="") + + def test_bug_308(self): + self.check_coverage("""\ + def run(): + for i in range(10): + yield lambda: i + + for f in run(): + print(f()) + """, + arcz=".1 15 56 65 5. .2 23 32 2. .3 3-3", + arcz_missing="", + arcz_unpredicted="") + + self.check_coverage("""\ + def run(): + yield lambda: 100 + for i in range(10): + yield lambda: i + + for f in run(): + print(f()) + """, + arcz=".1 16 67 76 6. .2 23 34 43 3. 2-2 .4 4-4", + arcz_missing="", + arcz_unpredicted="") + + self.check_coverage("""\ + def run(): + yield lambda: 100 # no branch miss + + for f in run(): + print(f()) + """, + arcz=".1 14 45 54 4. .2 2. 2-2", + arcz_missing="", + arcz_unpredicted="") + + def test_bug_324(self): + # This code is tricky: the list() call pulls all the values from gen(), + # but each of them is a generator itself that is never iterated. As a + # result, the generator expression on line 3 is never entered or run. + self.check_coverage("""\ + def gen(inp): + for n in inp: + yield (i * 2 for i in range(n)) + + list(gen([1,2,3])) + """, + arcz= + ".1 15 5. " # The module level + ".2 23 32 2. " # The gen() function + ".3 3-3", # The generator expression + arcz_missing=".3 3-3", + arcz_unpredicted="") + + def test_coroutines(self): + self.check_coverage("""\ + def double_inputs(): + while [1]: # avoid compiler differences + x = yield + x *= 2 + yield x + + gen = double_inputs() + next(gen) + print(gen.send(10)) + next(gen) + print(gen.send(6)) + """, + arcz= + ".1 17 78 89 9A AB B. " + ".2 23 34 45 52 2.", + arcz_missing="2.", + arcz_unpredicted="") + self.assertEqual(self.stdout(), "20\n12\n") + + class MiscArcTest(CoverageTest): """Miscellaneous arc-measuring tests.""" diff --git a/tests/test_html.py b/tests/test_html.py index 004ebbf..6f0b294 100644 --- a/tests/test_html.py +++ b/tests/test_html.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Tests that HTML generation is awesome.""" +import datetime import os.path import re @@ -53,6 +54,23 @@ class HtmlTestHelpers(CoverageTest): with open(filename) as f: return f.read() + def get_html_index_content(self, scrub_time_stamp=True): + """Return the content of index.html. + + If `scrub_time_stamp` is true, then replace the timestamp with a + placeholder so that clocks don't matter. + + """ + with open("htmlcov/index.html") as f: + index = f.read() + if scrub_time_stamp: + index = re.sub( + r"created at \d{4}-\d{2}-\d{2} \d{2}:\d{2}", + r"created at YYYY-MM-DD HH:MM", + index, + ) + return index + class HtmlDeltaTest(HtmlTestHelpers, CoverageTest): """Tests of the HTML delta speed-ups.""" @@ -86,8 +104,7 @@ class HtmlDeltaTest(HtmlTestHelpers, CoverageTest): # In this case, helper1 changes because its source is different. self.create_initial_files() self.run_coverage() - with open("htmlcov/index.html") as f: - index1 = f.read() + index1 = self.get_html_index_content() self.remove_html_files() # Now change a file and do it again @@ -104,8 +121,7 @@ class HtmlDeltaTest(HtmlTestHelpers, CoverageTest): self.assert_exists("htmlcov/helper1_py.html") self.assert_doesnt_exist("htmlcov/main_file_py.html") self.assert_doesnt_exist("htmlcov/helper2_py.html") - with open("htmlcov/index.html") as f: - index2 = f.read() + index2 = self.get_html_index_content() self.assertMultiLineEqual(index1, index2) def test_html_delta_from_coverage_change(self): @@ -136,8 +152,7 @@ class HtmlDeltaTest(HtmlTestHelpers, CoverageTest): # changed. self.create_initial_files() self.run_coverage(covargs=dict(omit=[])) - with open("htmlcov/index.html") as f: - index1 = f.read() + index1 = self.get_html_index_content() self.remove_html_files() self.run_coverage(covargs=dict(omit=['xyzzy*'])) @@ -147,8 +162,7 @@ class HtmlDeltaTest(HtmlTestHelpers, CoverageTest): self.assert_exists("htmlcov/helper1_py.html") self.assert_exists("htmlcov/main_file_py.html") self.assert_exists("htmlcov/helper2_py.html") - with open("htmlcov/index.html") as f: - index2 = f.read() + index2 = self.get_html_index_content() self.assertMultiLineEqual(index1, index2) def test_html_delta_from_coverage_version_change(self): @@ -157,8 +171,7 @@ class HtmlDeltaTest(HtmlTestHelpers, CoverageTest): # changed. self.create_initial_files() self.run_coverage() - with open("htmlcov/index.html") as f: - index1 = f.read() + index1 = self.get_html_index_content() self.remove_html_files() # "Upgrade" coverage.py! @@ -171,8 +184,7 @@ class HtmlDeltaTest(HtmlTestHelpers, CoverageTest): self.assert_exists("htmlcov/helper1_py.html") self.assert_exists("htmlcov/main_file_py.html") self.assert_exists("htmlcov/helper2_py.html") - with open("htmlcov/index.html") as f: - index2 = f.read() + index2 = self.get_html_index_content() fixed_index2 = index2.replace("XYZZY", self.real_coverage_version) self.assertMultiLineEqual(index1, fixed_index2) @@ -183,8 +195,7 @@ class HtmlTitleTest(HtmlTestHelpers, CoverageTest): def test_default_title(self): self.create_initial_files() self.run_coverage() - with open("htmlcov/index.html") as f: - index = f.read() + index = self.get_html_index_content() self.assertIn("<title>Coverage report</title>", index) self.assertIn("<h1>Coverage report:", index) @@ -192,8 +203,7 @@ class HtmlTitleTest(HtmlTestHelpers, CoverageTest): self.create_initial_files() self.make_file(".coveragerc", "[html]\ntitle = Metrics & stuff!\n") self.run_coverage() - with open("htmlcov/index.html") as f: - index = f.read() + index = self.get_html_index_content() self.assertIn("<title>Metrics & stuff!</title>", index) self.assertIn("<h1>Metrics & stuff!:", index) @@ -203,8 +213,7 @@ class HtmlTitleTest(HtmlTestHelpers, CoverageTest): "[html]\ntitle = «ταБЬℓσ» numbers" ) self.run_coverage() - with open("htmlcov/index.html") as f: - index = f.read() + index = self.get_html_index_content() self.assertIn( "<title>«ταБЬℓσ»" " numbers", index @@ -218,8 +227,7 @@ class HtmlTitleTest(HtmlTestHelpers, CoverageTest): self.create_initial_files() self.make_file(".coveragerc", "[html]\ntitle = Good title\n") self.run_coverage(htmlargs=dict(title="«ταБЬℓσ» & stüff!")) - with open("htmlcov/index.html") as f: - index = f.read() + index = self.get_html_index_content() self.assertIn( "<title>«ταБЬℓσ»" " & stüff!</title>", index @@ -333,7 +341,7 @@ class HtmlWithUnparsableFilesTest(HtmlTestHelpers, CoverageTest): self.assertIn("line_two", formfeed_html) -class HtmlTest(CoverageTest): +class HtmlTest(HtmlTestHelpers, CoverageTest): """Moar HTML tests.""" def test_missing_source_file_incorrect_message(self): @@ -363,6 +371,32 @@ class HtmlTest(CoverageTest): self.assert_exists("htmlcov/afile.html") self.assert_exists("htmlcov/afile_py.html") + def test_has_date_stamp_in_files(self): + self.create_initial_files() + self.run_coverage() + + with open("htmlcov/index.html") as f: + self.assert_correct_timestamp(f.read()) + with open("htmlcov/main_file_py.html") as f: + self.assert_correct_timestamp(f.read()) + + def assert_correct_timestamp(self, html): + """Extract the timestamp from `html`, and assert it is recent.""" + timestamp_pat = r"created at (\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})" + m = re.search(timestamp_pat, html) + self.assertTrue(m, "Didn't find a timestamp!") + timestamp = datetime.datetime(*map(int, m.groups())) + age = datetime.datetime.now() - timestamp + # Python2.6 doesn't have total_seconds :( + self.assertEqual(age.days, 0) + # The timestamp only records the minute, so the delta could be from + # 12:00 to 12:01:59, or two minutes. + self.assertLessEqual( + abs(age.seconds), + 120, + "Timestamp is wrong: {0}".format(timestamp) + ) + class HtmlStaticFileTest(CoverageTest): """Tests of the static file copying for the HTML report.""" diff --git a/tests/test_parser.py b/tests/test_parser.py index 244d4c7..81916a9 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -34,6 +34,22 @@ class PythonParserTest(CoverageTest): 2:1, 3:1, 4:2, 5:1, 7:1, 9:1, 10:1 }) + def test_generator_exit_counts(self): + # https://bitbucket.org/ned/coveragepy/issue/324/yield-in-loop-confuses-branch-coverage + parser = self.parse_source("""\ + def gen(input): + for n in inp: + yield (i * 2 for i in range(n)) + + list(gen([1,2,3])) + """) + self.assertEqual(parser.exit_counts(), { + 1:1, # def -> list + 2:2, # for -> yield; for -> exit + 3:2, # yield -> for; genexp exit + 5:1, # list -> exit + }) + def test_try_except(self): parser = self.parse_source("""\ try: |