summaryrefslogtreecommitdiff
path: root/coverage
diff options
context:
space:
mode:
Diffstat (limited to 'coverage')
-rw-r--r--coverage/__init__.py6
-rw-r--r--coverage/annotate.py79
-rw-r--r--coverage/backunittest.py36
-rw-r--r--coverage/backward.py88
-rw-r--r--coverage/bytecode.py1
-rw-r--r--coverage/cmdline.py25
-rw-r--r--coverage/codeunit.py213
-rw-r--r--coverage/collector.py247
-rw-r--r--coverage/config.py80
-rw-r--r--coverage/control.py210
-rw-r--r--coverage/data.py19
-rw-r--r--coverage/debug.py2
-rw-r--r--coverage/django.py61
-rw-r--r--coverage/execfile.py126
-rw-r--r--coverage/files.py20
-rw-r--r--coverage/html.py13
-rw-r--r--coverage/misc.py11
-rw-r--r--coverage/parser.py75
-rw-r--r--coverage/phystokens.py2
-rw-r--r--coverage/plugin.py108
-rw-r--r--coverage/pytracer.py163
-rw-r--r--coverage/report.py20
-rw-r--r--coverage/results.py71
-rw-r--r--coverage/summary.py11
-rw-r--r--coverage/templite.py180
-rw-r--r--coverage/test_helpers.py258
-rw-r--r--coverage/tracer.c333
-rw-r--r--coverage/xmlreport.py20
28 files changed, 1839 insertions, 639 deletions
diff --git a/coverage/__init__.py b/coverage/__init__.py
index 193b7a10..5ae32aba 100644
--- a/coverage/__init__.py
+++ b/coverage/__init__.py
@@ -7,10 +7,14 @@ http://nedbatchelder.com/code/coverage
from coverage.version import __version__, __url__
-from coverage.control import coverage, process_startup
+from coverage.control import Coverage, process_startup
from coverage.data import CoverageData
from coverage.cmdline import main, CoverageScript
from coverage.misc import CoverageException
+from coverage.plugin import CoveragePlugin
+
+# Backward compatibility.
+coverage = Coverage
# Module-level functions. The original API to this module was based on
# functions defined directly in the module, with a singleton of the coverage()
diff --git a/coverage/annotate.py b/coverage/annotate.py
index 19777eaf..5b96448a 100644
--- a/coverage/annotate.py
+++ b/coverage/annotate.py
@@ -47,55 +47,44 @@ class AnnotateReporter(Reporter):
`cu` is the CodeUnit for the file to annotate.
"""
- if not cu.relative:
- return
+ statements = sorted(analysis.statements)
+ missing = sorted(analysis.missing)
+ excluded = sorted(analysis.excluded)
- filename = cu.filename
- source = cu.source_file()
if self.directory:
dest_file = os.path.join(self.directory, cu.flat_rootname())
dest_file += ".py,cover"
else:
- dest_file = filename + ",cover"
- dest = open(dest_file, 'w')
-
- statements = sorted(analysis.statements)
- missing = sorted(analysis.missing)
- excluded = sorted(analysis.excluded)
-
- lineno = 0
- i = 0
- j = 0
- covered = True
- while True:
- line = source.readline()
- if line == '':
- break
- lineno += 1
- while i < len(statements) and statements[i] < lineno:
- i += 1
- while j < len(missing) and missing[j] < lineno:
- j += 1
- if i < len(statements) and statements[i] == lineno:
- covered = j >= len(missing) or missing[j] > lineno
- if self.blank_re.match(line):
- dest.write(' ')
- elif self.else_re.match(line):
- # Special logic for lines containing only 'else:'.
- if i >= len(statements) and j >= len(missing):
- dest.write('! ')
- elif i >= len(statements) or j >= len(missing):
+ dest_file = cu.filename + ",cover"
+
+ with open(dest_file, 'w') as dest:
+ i = 0
+ j = 0
+ covered = True
+ source = cu.source()
+ for lineno, line in enumerate(source.splitlines(True), start=1):
+ while i < len(statements) and statements[i] < lineno:
+ i += 1
+ while j < len(missing) and missing[j] < lineno:
+ j += 1
+ if i < len(statements) and statements[i] == lineno:
+ covered = j >= len(missing) or missing[j] > lineno
+ if self.blank_re.match(line):
+ dest.write(' ')
+ elif self.else_re.match(line):
+ # Special logic for lines containing only 'else:'.
+ if i >= len(statements) and j >= len(missing):
+ dest.write('! ')
+ elif i >= len(statements) or j >= len(missing):
+ dest.write('> ')
+ elif statements[i] == missing[j]:
+ dest.write('! ')
+ else:
+ dest.write('> ')
+ elif lineno in excluded:
+ dest.write('- ')
+ elif covered:
dest.write('> ')
- elif statements[i] == missing[j]:
- dest.write('! ')
else:
- dest.write('> ')
- elif lineno in excluded:
- dest.write('- ')
- elif covered:
- dest.write('> ')
- else:
- dest.write('! ')
- dest.write(line)
- source.close()
- dest.close()
+ dest.write('! ')
+ dest.write(line)
diff --git a/coverage/backunittest.py b/coverage/backunittest.py
new file mode 100644
index 00000000..b2b7ca2f
--- /dev/null
+++ b/coverage/backunittest.py
@@ -0,0 +1,36 @@
+"""Implementations of unittest features from the future."""
+
+# Use unittest2 if it's available, otherwise unittest. This gives us
+# backported features for 2.6.
+try:
+ import unittest2 as unittest # pylint: disable=F0401
+except ImportError:
+ import unittest
+
+
+def unittest_has(method):
+ """Does `unitttest.TestCase` have `method` defined?"""
+ return hasattr(unittest.TestCase, method)
+
+
+class TestCase(unittest.TestCase):
+ """Just like unittest.TestCase, but with assert methods added.
+
+ Designed to be compatible with 3.1 unittest. Methods are only defined if
+ `unittest` doesn't have them.
+
+ """
+ # pylint: disable=missing-docstring
+
+ if not unittest_has('assertCountEqual'):
+ def assertCountEqual(self, s1, s2):
+ """Assert these have the same elements, regardless of order."""
+ self.assertEqual(set(s1), set(s2))
+
+ if not unittest_has('assertRaisesRegex'):
+ def assertRaisesRegex(self, *args, **kwargs):
+ return self.assertRaisesRegexp(*args, **kwargs)
+
+ if not unittest_has('assertRegex'):
+ def assertRegex(self, *args, **kwargs):
+ return self.assertRegexpMatches(*args, **kwargs)
diff --git a/coverage/backward.py b/coverage/backward.py
index a0dc9027..9597449c 100644
--- a/coverage/backward.py
+++ b/coverage/backward.py
@@ -1,14 +1,15 @@
"""Add things to old Pythons so I can pretend they are newer."""
# This file does lots of tricky stuff, so disable a bunch of lintisms.
-# pylint: disable=F0401,W0611,W0622
-# F0401: Unable to import blah
-# W0611: Unused import blah
-# W0622: Redefining built-in blah
+# pylint: disable=redefined-builtin
+# pylint: disable=import-error
+# pylint: disable=no-member
+# pylint: disable=unused-import
+# pylint: disable=no-name-in-module
import os, re, sys
-# Pythons 2 and 3 differ on where to get StringIO
+# Pythons 2 and 3 differ on where to get StringIO.
try:
from cStringIO import StringIO
BytesIO = StringIO
@@ -49,24 +50,9 @@ else:
if sys.version_info >= (3, 0):
# Python 3.2 provides `tokenize.open`, the best way to open source files.
import tokenize
- try:
- open_source = tokenize.open # pylint: disable=E1101
- except AttributeError:
- from io import TextIOWrapper
- detect_encoding = tokenize.detect_encoding # pylint: disable=E1101
- # Copied from the 3.2 stdlib:
- def open_source(fname):
- """Open a file in read only mode using the encoding detected by
- detect_encoding().
- """
- buffer = open(fname, 'rb')
- encoding, _ = detect_encoding(buffer.readline)
- buffer.seek(0)
- text = TextIOWrapper(buffer, encoding, line_buffering=True)
- text.mode = 'r'
- return text
+ open_python_source = tokenize.open # pylint: disable=E1101
else:
- def open_source(fname):
+ def open_python_source(fname):
"""Open a source file the best way."""
return open(fname, "rU")
@@ -117,10 +103,58 @@ else:
for byte in bytes_value:
yield ord(byte)
-# Md5 is available in different places.
+
+try:
+ # In Py 2.x, the builtins were in __builtin__
+ BUILTINS = sys.modules['__builtin__']
+except KeyError:
+ # In Py 3.x, they're in builtins
+ BUILTINS = sys.modules['builtins']
+
+
+# imp was deprecated in Python 3.3
try:
- import hashlib
- md5 = hashlib.md5
+ import importlib, importlib.util
+ imp = None
except ImportError:
- import md5
- md5 = md5.new
+ importlib = None
+
+# we only want to use importlib if it has everything we need.
+try:
+ importlib_util_find_spec = importlib.util.find_spec
+except Exception:
+ import imp
+ importlib_util_find_spec = None
+
+try:
+ PYC_MAGIC_NUMBER = importlib.util.MAGIC_NUMBER
+except AttributeError:
+ PYC_MAGIC_NUMBER = imp.get_magic()
+
+
+def import_local_file(modname):
+ """Import a local file as a module.
+
+ Opens a file in the current directory named `modname`.py, imports it
+ as `modname`, and returns the module object.
+
+ """
+ try:
+ from importlib.machinery import SourceFileLoader
+ except ImportError:
+ SourceFileLoader = None
+
+ modfile = modname + '.py'
+ if SourceFileLoader:
+ mod = SourceFileLoader(modname, modfile).load_module()
+ else:
+ for suff in imp.get_suffixes():
+ if suff[0] == '.py':
+ break
+
+ with open(modfile, 'r') as f:
+ # pylint: disable=W0631
+ # (Using possibly undefined loop variable 'suff')
+ mod = imp.load_module(modname, f, modfile, suff)
+
+ return mod
diff --git a/coverage/bytecode.py b/coverage/bytecode.py
index 85360638..3f62dfaf 100644
--- a/coverage/bytecode.py
+++ b/coverage/bytecode.py
@@ -29,7 +29,6 @@ class ByteCodes(object):
Returns `ByteCode` objects.
"""
- # pylint: disable=R0924
def __init__(self, code):
self.code = code
diff --git a/coverage/cmdline.py b/coverage/cmdline.py
index 9b807040..c723fa95 100644
--- a/coverage/cmdline.py
+++ b/coverage/cmdline.py
@@ -1,6 +1,6 @@
"""Command-line support for Coverage."""
-import optparse, os, sys, time, traceback
+import glob, optparse, os, sys, time, traceback
from coverage.execfile import run_python_file, run_python_module
from coverage.misc import CoverageException, ExceptionDuringRun, NoSource
@@ -449,7 +449,7 @@ class CoverageScript(object):
# Remaining actions are reporting, with some common options.
report_args = dict(
- morfs = args,
+ morfs = unglob_args(args),
ignore_errors = options.ignore_errors,
omit = omit,
include = include,
@@ -470,6 +470,14 @@ class CoverageScript(object):
total = self.coverage.xml_report(outfile=outfile, **report_args)
if options.fail_under is not None:
+ # Total needs to be rounded, but be careful of 0 and 100.
+ if 0 < total < 1:
+ total = 1
+ elif 99 < total < 100:
+ total = 99
+ else:
+ total = round(total)
+
if total >= options.fail_under:
return OK
else:
@@ -633,6 +641,19 @@ def unshell_list(s):
return s.split(',')
+def unglob_args(args):
+ """Interpret shell wildcards for platforms that need it."""
+ if sys.platform == 'win32':
+ globbed = []
+ for arg in args:
+ if '?' in arg or '*' in arg:
+ globbed.extend(glob.glob(arg))
+ else:
+ globbed.append(arg)
+ args = globbed
+ return args
+
+
HELP_TOPICS = {
# -------------------------
'classic':
diff --git a/coverage/codeunit.py b/coverage/codeunit.py
index c58e237b..c9ab2622 100644
--- a/coverage/codeunit.py
+++ b/coverage/codeunit.py
@@ -1,18 +1,24 @@
"""Code unit (module) handling for Coverage."""
-import glob, os
+import os
-from coverage.backward import open_source, string_class, StringIO
-from coverage.misc import CoverageException
+from coverage.backward import open_python_source, string_class
+from coverage.misc import CoverageException, NoSource
+from coverage.parser import CodeParser, PythonParser
+from coverage.phystokens import source_token_lines, source_encoding
-def code_unit_factory(morfs, file_locator):
+def code_unit_factory(morfs, file_locator, get_plugin=None):
"""Construct a list of CodeUnits from polymorphic inputs.
`morfs` is a module or a filename, or a list of same.
`file_locator` is a FileLocator that can help resolve filenames.
+ `get_plugin` is a function taking a filename, and returning a plugin
+ responsible for the file. It can also return None if there is no plugin
+ claiming the file.
+
Returns a list of CodeUnit objects.
"""
@@ -20,16 +26,30 @@ def code_unit_factory(morfs, file_locator):
if not isinstance(morfs, (list, tuple)):
morfs = [morfs]
- # On Windows, the shell doesn't expand wildcards. Do it here.
- globbed = []
+ code_units = []
for morf in morfs:
- if isinstance(morf, string_class) and ('?' in morf or '*' in morf):
- globbed.extend(glob.glob(morf))
+ plugin = None
+ if isinstance(morf, string_class) and get_plugin:
+ plugin = get_plugin(morf)
+ if plugin:
+ klass = plugin.code_unit_class(morf)
+ #klass = DjangoTracer # NOT REALLY! TODO
+ # Hacked-in Mako support. Define COVERAGE_MAKO_PATH as a fragment of
+ # the path that indicates the Python file is actually a compiled Mako
+ # template. THIS IS TEMPORARY!
+ #MAKO_PATH = os.environ.get('COVERAGE_MAKO_PATH')
+ #if MAKO_PATH and isinstance(morf, string_class) and MAKO_PATH in morf:
+ # # Super hack! Do mako both ways!
+ # if 0:
+ # cu = PythonCodeUnit(morf, file_locator)
+ # cu.name += '_fako'
+ # code_units.append(cu)
+ # klass = MakoCodeUnit
+ #elif isinstance(morf, string_class) and morf.endswith(".html"):
+ # klass = DjangoCodeUnit
else:
- globbed.append(morf)
- morfs = globbed
-
- code_units = [CodeUnit(morf, file_locator) for morf in morfs]
+ klass = PythonCodeUnit
+ code_units.append(klass(morf, file_locator))
return code_units
@@ -44,6 +64,7 @@ class CodeUnit(object):
`relative` is a boolean.
"""
+
def __init__(self, morf, file_locator):
self.file_locator = file_locator
@@ -51,11 +72,7 @@ class CodeUnit(object):
f = morf.__file__
else:
f = morf
- # .pyc files should always refer to a .py instead.
- if f.endswith(('.pyc', '.pyo')):
- f = f[:-1]
- elif f.endswith('$py.class'): # Jython
- f = f[:-9] + ".py"
+ f = self._adjust_filename(f)
self.filename = self.file_locator.canonical_filename(f)
if hasattr(morf, '__name__'):
@@ -73,9 +90,15 @@ class CodeUnit(object):
self.name = n
self.modname = modname
+ self._source = None
+
def __repr__(self):
return "<CodeUnit name=%r filename=%r>" % (self.name, self.filename)
+ def _adjust_filename(self, f):
+ # TODO: This shouldn't be in the base class, right?
+ return f
+
# Annoying comparison operators. Py3k wants __lt__ etc, and Py2k needs all
# of them defined.
@@ -99,7 +122,7 @@ class CodeUnit(object):
the same directory, but need to differentiate same-named files from
different directories.
- For example, the file a/b/c.py might return 'a_b_c'
+ For example, the file a/b/c.py will return 'a_b_c'
"""
if self.modname:
@@ -108,26 +131,105 @@ class CodeUnit(object):
root = os.path.splitdrive(self.name)[1]
return root.replace('\\', '_').replace('/', '_').replace('.', '_')
- def source_file(self):
- """Return an open file for reading the source of the code unit."""
+ def source(self):
+ if self._source is None:
+ self._source = self.get_source()
+ return self._source
+
+ def get_source(self):
+ """Return the source code, as a string."""
if os.path.exists(self.filename):
# A regular text file: open it.
- return open_source(self.filename)
+ with open_python_source(self.filename) as f:
+ return f.read()
# Maybe it's in a zip file?
source = self.file_locator.get_zip_data(self.filename)
if source is not None:
- return StringIO(source)
+ return source
# Couldn't find source.
raise CoverageException(
"No source for code '%s'." % self.filename
)
+ def source_token_lines(self):
+ """Return the 'tokenized' text for the code."""
+ for line in self.source().splitlines():
+ yield [('txt', line)]
+
def should_be_python(self):
"""Does it seem like this file should contain Python?
- This is used to decide if a file reported as part of the exection of
+ This is used to decide if a file reported as part of the execution of
+ a program was really likely to have contained Python in the first
+ place.
+ """
+ return False
+
+ def get_parser(self, exclude=None):
+ raise NotImplementedError
+
+
+class PythonCodeUnit(CodeUnit):
+ """Represents a Python file."""
+
+ def _adjust_filename(self, fname):
+ # .pyc files should always refer to a .py instead.
+ if fname.endswith(('.pyc', '.pyo')):
+ fname = fname[:-1]
+ elif fname.endswith('$py.class'): # Jython
+ fname = fname[:-9] + ".py"
+ return fname
+
+ def get_parser(self, exclude=None):
+ actual_filename, source = self._find_source(self.filename)
+ return PythonParser(
+ text=source, filename=actual_filename, exclude=exclude,
+ )
+
+ def _find_source(self, filename):
+ """Find the source for `filename`.
+
+ Returns two values: the actual filename, and the source.
+
+ The source returned depends on which of these cases holds:
+
+ * The filename seems to be a non-source file: returns None
+
+ * The filename is a source file, and actually exists: returns None.
+
+ * The filename is a source file, and is in a zip file or egg:
+ returns the source.
+
+ * The filename is a source file, but couldn't be found: raises
+ `NoSource`.
+
+ """
+ source = None
+
+ base, ext = os.path.splitext(filename)
+ TRY_EXTS = {
+ '.py': ['.py', '.pyw'],
+ '.pyw': ['.pyw'],
+ }
+ try_exts = TRY_EXTS.get(ext)
+ if not try_exts:
+ return filename, None
+
+ for try_ext in try_exts:
+ try_filename = base + try_ext
+ if os.path.exists(try_filename):
+ return try_filename, None
+ source = self.file_locator.get_zip_data(try_filename)
+ if source:
+ return try_filename, source
+ raise NoSource("No source for code: '%s'" % filename)
+
+ def should_be_python(self):
+ """Does it seem like this file should contain Python?
+
+ This is used to decide if a file reported as part of the execution of
a program was really likely to have contained Python in the first
place.
@@ -143,3 +245,68 @@ class CodeUnit(object):
return True
# Everything else is probably not Python.
return False
+
+ def source_token_lines(self):
+ return source_token_lines(self.source())
+
+ def source_encoding(self):
+ return source_encoding(self.source())
+
+
+class MakoParser(CodeParser):
+ def __init__(self, metadata):
+ self.metadata = metadata
+
+ def parse_source(self):
+ """Returns executable_line_numbers, excluded_line_numbers"""
+ executable = set(self.metadata['line_map'].values())
+ return executable, set()
+
+ def translate_lines(self, lines):
+ tlines = set()
+ for l in lines:
+ try:
+ tlines.add(self.metadata['full_line_map'][l])
+ except IndexError:
+ pass
+ return tlines
+
+
+class MakoCodeUnit(CodeUnit):
+ def __init__(self, *args, **kwargs):
+ super(MakoCodeUnit, self).__init__(*args, **kwargs)
+ from mako.template import ModuleInfo
+ py_source = open(self.filename).read()
+ self.metadata = ModuleInfo.get_module_source_metadata(py_source, full_line_map=True)
+
+ def get_source(self):
+ return open(self.metadata['filename']).read()
+
+ def get_parser(self, exclude=None):
+ return MakoParser(self.metadata)
+
+ def source_encoding(self):
+ return self.metadata['source_encoding']
+
+
+class DjangoCodeUnit(CodeUnit):
+ def get_source(self):
+ with open(self.filename) as f:
+ return f.read()
+
+ def get_parser(self, exclude=None):
+ return DjangoParser(self.filename)
+
+ def source_encoding(self):
+ return "utf8"
+
+
+class DjangoParser(CodeParser):
+ def __init__(self, filename):
+ self.filename = filename
+
+ def parse_source(self):
+ with open(self.filename) as f:
+ source = f.read()
+ executable = set(range(1, len(source.splitlines())+1))
+ return executable, set()
diff --git a/coverage/collector.py b/coverage/collector.py
index 94af5df5..97b45deb 100644
--- a/coverage/collector.py
+++ b/coverage/collector.py
@@ -1,6 +1,9 @@
"""Raw data collector for Coverage."""
-import collections, os, sys, threading
+import os, sys
+
+from coverage.misc import CoverageException
+from coverage.pytracer import PyTracer
try:
# Use the C extension code when we can, for speed.
@@ -21,147 +24,6 @@ except ImportError:
CTracer = None
-class PyTracer(object):
- """Python implementation of the raw data tracer."""
-
- # Because of poor implementations of trace-function-manipulating tools,
- # the Python trace function must be kept very simple. In particular, there
- # must be only one function ever set as the trace function, both through
- # sys.settrace, and as the return value from the trace function. Put
- # another way, the trace function must always return itself. It cannot
- # swap in other functions, or return None to avoid tracing a particular
- # frame.
- #
- # The trace manipulator that introduced this restriction is DecoratorTools,
- # which sets a trace function, and then later restores the pre-existing one
- # by calling sys.settrace with a function it found in the current frame.
- #
- # Systems that use DecoratorTools (or similar trace manipulations) must use
- # PyTracer to get accurate results. The command-line --timid argument is
- # used to force the use of this tracer.
-
- def __init__(self):
- self.data = None
- self.should_trace = None
- self.should_trace_cache = None
- self.warn = None
- self.cur_file_data = None
- self.last_line = 0
- self.data_stack = []
- self.data_stacks = collections.defaultdict(list)
- self.last_exc_back = None
- self.last_exc_firstlineno = 0
- self.arcs = False
- self.thread = None
- self.stopped = False
- self.coroutine_id_func = None
- self.last_coroutine = None
-
- def _trace(self, frame, event, arg_unused):
- """The trace function passed to sys.settrace."""
-
- if self.stopped:
- return
-
- if 0:
- sys.stderr.write("trace event: %s %r @%d\n" % (
- event, frame.f_code.co_filename, frame.f_lineno
- ))
-
- if self.last_exc_back:
- if frame == self.last_exc_back:
- # Someone forgot a return event.
- if self.arcs and self.cur_file_data:
- pair = (self.last_line, -self.last_exc_firstlineno)
- self.cur_file_data[pair] = None
- if self.coroutine_id_func:
- self.data_stack = self.data_stacks[self.coroutine_id_func()]
- self.cur_file_data, self.last_line = self.data_stack.pop()
- self.last_exc_back = None
-
- if event == 'call':
- # Entering a new function context. Decide if we should trace
- # in this file.
- if self.coroutine_id_func:
- self.data_stack = self.data_stacks[self.coroutine_id_func()]
- self.last_coroutine = self.coroutine_id_func()
- self.data_stack.append((self.cur_file_data, self.last_line))
- filename = frame.f_code.co_filename
- if filename not in self.should_trace_cache:
- tracename = self.should_trace(filename, frame)
- self.should_trace_cache[filename] = tracename
- else:
- tracename = self.should_trace_cache[filename]
- #print("called, stack is %d deep, tracename is %r" % (
- # len(self.data_stack), tracename))
- if tracename:
- if tracename not in self.data:
- self.data[tracename] = {}
- self.cur_file_data = self.data[tracename]
- else:
- self.cur_file_data = None
- # Set the last_line to -1 because the next arc will be entering a
- # code block, indicated by (-1, n).
- self.last_line = -1
- elif event == 'line':
- # Record an executed line.
- #if self.coroutine_id_func:
- # assert self.last_coroutine == self.coroutine_id_func()
- if self.cur_file_data is not None:
- if self.arcs:
- #print("lin", self.last_line, frame.f_lineno)
- self.cur_file_data[(self.last_line, frame.f_lineno)] = None
- else:
- #print("lin", frame.f_lineno)
- self.cur_file_data[frame.f_lineno] = None
- self.last_line = frame.f_lineno
- elif event == 'return':
- if self.arcs and self.cur_file_data:
- first = frame.f_code.co_firstlineno
- self.cur_file_data[(self.last_line, -first)] = None
- # Leaving this function, pop the filename stack.
- if self.coroutine_id_func:
- self.data_stack = self.data_stacks[self.coroutine_id_func()]
- self.last_coroutine = self.coroutine_id_func()
- self.cur_file_data, self.last_line = self.data_stack.pop()
- #print("returned, stack is %d deep" % (len(self.data_stack)))
- elif event == 'exception':
- #print("exc", self.last_line, frame.f_lineno)
- self.last_exc_back = frame.f_back
- self.last_exc_firstlineno = frame.f_code.co_firstlineno
- return self._trace
-
- def start(self):
- """Start this Tracer.
-
- Return a Python function suitable for use with sys.settrace().
-
- """
- self.thread = threading.currentThread()
- sys.settrace(self._trace)
- return self._trace
-
- def stop(self):
- """Stop this Tracer."""
- self.stopped = True
- if self.thread != threading.currentThread():
- # Called on a different thread than started us: we can't unhook
- # ourseves, but we've set the flag that we should stop, so we won't
- # do any more tracing.
- return
-
- if hasattr(sys, "gettrace") and self.warn:
- if sys.gettrace() != self._trace:
- msg = "Trace function changed, measurement is likely wrong: %r"
- self.warn(msg % (sys.gettrace(),))
- #print("Stopping tracer on %s" % threading.current_thread().ident)
- sys.settrace(None)
-
- def get_stats(self):
- """Return a dictionary of statistics, or None."""
- return None
-
-
class Collector(object):
"""Collects trace data.
@@ -183,13 +45,17 @@ class Collector(object):
# the top, and resumed when they become the top again.
_collectors = []
- def __init__(self, should_trace, timid, branch, warn, coroutine):
+ def __init__(self,
+ should_trace, check_include, timid, branch, warn, coroutine,
+ ):
"""Create a collector.
`should_trace` is a function, taking a filename, and returning a
canonicalized filename, or None depending on whether the file should
be traced or not.
+ TODO: `check_include`
+
If `timid` is true, then a slower simpler trace function will be
used. This is important for some environments where manipulation of
tracing functions make the faster more sophisticated trace function not
@@ -202,21 +68,44 @@ class Collector(object):
`warn` is a warning function, taking a single string message argument,
to be used if a warning needs to be issued.
+ TODO: `coroutine`
+
"""
self.should_trace = should_trace
+ self.check_include = check_include
self.warn = warn
self.branch = branch
- if coroutine == "greenlet":
- import greenlet
- self.coroutine_id_func = greenlet.getcurrent
- elif coroutine == "eventlet":
- import eventlet.greenthread
- self.coroutine_id_func = eventlet.greenthread.getcurrent
- elif coroutine == "gevent":
- import gevent
- self.coroutine_id_func = gevent.getcurrent
- else:
- self.coroutine_id_func = None
+ self.threading = None
+ self.coroutine = coroutine
+
+ self.coroutine_id_func = None
+
+ try:
+ if coroutine == "greenlet":
+ import greenlet
+ self.coroutine_id_func = greenlet.getcurrent
+ elif coroutine == "eventlet":
+ import eventlet.greenthread
+ self.coroutine_id_func = eventlet.greenthread.getcurrent
+ elif coroutine == "gevent":
+ import gevent
+ self.coroutine_id_func = gevent.getcurrent
+ elif coroutine == "thread" or not coroutine:
+ # It's important to import threading only if we need it. If
+ # it's imported early, and the program being measured uses
+ # gevent, then gevent's monkey-patching won't work properly.
+ import threading
+ self.threading = threading
+ else:
+ raise CoverageException(
+ "Don't understand coroutine=%s" % coroutine
+ )
+ except ImportError:
+ raise CoverageException(
+ "Couldn't trace with coroutine=%s, "
+ "the module isn't installed." % coroutine
+ )
+
self.reset()
if timid:
@@ -240,6 +129,8 @@ class Collector(object):
# or mapping filenames to dicts with linenumber pairs as keys.
self.data = {}
+ self.plugin_data = {}
+
# A cache of the results from should_trace, the decision about whether
# to trace execution in a file. A dict of filename to (filename or
# None).
@@ -256,10 +147,25 @@ class Collector(object):
tracer.should_trace = self.should_trace
tracer.should_trace_cache = self.should_trace_cache
tracer.warn = self.warn
+
if hasattr(tracer, 'coroutine_id_func'):
tracer.coroutine_id_func = self.coroutine_id_func
+ elif self.coroutine_id_func:
+ raise CoverageException(
+ "Can't support coroutine=%s with %s, "
+ "only threads are supported" % (
+ self.coroutine, self.tracer_name(),
+ )
+ )
+
+ if hasattr(tracer, 'plugin_data'):
+ tracer.plugin_data = self.plugin_data
+ if hasattr(tracer, 'threading'):
+ tracer.threading = self.threading
+
fn = tracer.start()
self.tracers.append(tracer)
+
return fn
# The trace function has to be set individually on each thread before
@@ -286,20 +192,19 @@ class Collector(object):
if self._collectors:
self._collectors[-1].pause()
self._collectors.append(self)
- #print("Started: %r" % self._collectors, file=sys.stderr)
# Check to see whether we had a fullcoverage tracer installed.
traces0 = []
- if hasattr(sys, "gettrace"):
- fn0 = sys.gettrace()
- if fn0:
- tracer0 = getattr(fn0, '__self__', None)
- if tracer0:
- traces0 = getattr(tracer0, 'traces', [])
+ fn0 = sys.gettrace()
+ if fn0:
+ tracer0 = getattr(fn0, '__self__', None)
+ if tracer0:
+ traces0 = getattr(tracer0, 'traces', [])
# Install the tracer on this thread.
fn = self._start_tracer()
+ # Replay all the events from fullcoverage into the new trace function.
for args in traces0:
(frame, event, arg), lineno = args
try:
@@ -311,11 +216,11 @@ class Collector(object):
# Install our installation tracer in threading, to jump start other
# threads.
- threading.settrace(self._installation_trace)
+ if self.threading:
+ self.threading.settrace(self._installation_trace)
def stop(self):
"""Stop collecting trace information."""
- #print >>sys.stderr, "Stopping: %r" % self._collectors
assert self._collectors
assert self._collectors[-1] is self
@@ -337,13 +242,17 @@ class Collector(object):
print("\nCoverage.py tracer stats:")
for k in sorted(stats.keys()):
print("%16s: %s" % (k, stats[k]))
- threading.settrace(None)
+ if self.threading:
+ self.threading.settrace(None)
def resume(self):
"""Resume tracing after a `pause`."""
for tracer in self.tracers:
tracer.start()
- threading.settrace(self._installation_trace)
+ if self.threading:
+ self.threading.settrace(self._installation_trace)
+ else:
+ self._start_tracer()
def get_line_data(self):
"""Return the line data collected.
@@ -356,10 +265,7 @@ class Collector(object):
# to show line data.
line_data = {}
for f, arcs in self.data.items():
- line_data[f] = ldf = {}
- for l1, _ in list(arcs.keys()):
- if l1:
- ldf[l1] = None
+ line_data[f] = dict((l1, None) for l1, _ in arcs.keys() if l1)
return line_data
else:
return self.data
@@ -377,3 +283,6 @@ class Collector(object):
return self.data
else:
return {}
+
+ def get_plugin_data(self):
+ return self.plugin_data
diff --git a/coverage/config.py b/coverage/config.py
index 60ec3f41..c671ef75 100644
--- a/coverage/config.py
+++ b/coverage/config.py
@@ -13,6 +13,11 @@ except ImportError:
class HandyConfigParser(configparser.RawConfigParser):
"""Our specialization of ConfigParser."""
+ def __init__(self, section_prefix):
+ # pylint: disable=super-init-not-called
+ configparser.RawConfigParser.__init__(self)
+ self.section_prefix = section_prefix
+
def read(self, filename):
"""Read a filename as UTF-8 configuration data."""
kwargs = {}
@@ -20,8 +25,37 @@ class HandyConfigParser(configparser.RawConfigParser):
kwargs['encoding'] = "utf-8"
return configparser.RawConfigParser.read(self, filename, **kwargs)
- def get(self, *args, **kwargs):
- v = configparser.RawConfigParser.get(self, *args, **kwargs)
+ def has_option(self, section, option):
+ section = self.section_prefix + section
+ return configparser.RawConfigParser.has_option(self, section, option)
+
+ def has_section(self, section):
+ section = self.section_prefix + section
+ return configparser.RawConfigParser.has_section(self, section)
+
+ def options(self, section):
+ section = self.section_prefix + section
+ return configparser.RawConfigParser.options(self, section)
+
+ def get_section(self, section):
+ """Get the contents of a section, as a dictionary."""
+ d = {}
+ for opt in self.options(section):
+ d[opt] = self.get(section, opt)
+ return d
+
+ def get(self, section, *args, **kwargs):
+ """Get a value, replacing environment variables also.
+
+ The arguments are the same as `RawConfigParser.get`, but in the found
+ value, ``$WORD`` or ``${WORD}`` are replaced by the value of the
+ environment variable ``WORD``.
+
+ Returns the finished value.
+
+ """
+ section = self.section_prefix + section
+ v = configparser.RawConfigParser.get(self, section, *args, **kwargs)
def dollar_replace(m):
"""Called for each $replacement."""
# Only one of the groups will have matched, just get its text.
@@ -113,6 +147,7 @@ class CoverageConfig(object):
self.timid = False
self.source = None
self.debug = []
+ self.plugins = []
# Defaults for [report]
self.exclude_list = DEFAULT_EXCLUDE[:]
@@ -135,6 +170,9 @@ class CoverageConfig(object):
# Defaults for [paths]
self.paths = {}
+ # Options for plugins
+ self.plugin_options = {}
+
def from_environment(self, env_var):
"""Read configuration from the `env_var` environment variable."""
# Timidity: for nose users, read an environment variable. This is a
@@ -144,7 +182,7 @@ class CoverageConfig(object):
if env:
self.timid = ('--timid' in env)
- MUST_BE_LIST = ["omit", "include", "debug"]
+ MUST_BE_LIST = ["omit", "include", "debug", "plugins"]
def from_args(self, **kwargs):
"""Read config values from `kwargs`."""
@@ -154,34 +192,54 @@ class CoverageConfig(object):
v = [v]
setattr(self, k, v)
- def from_file(self, filename):
+ def from_file(self, filename, section_prefix=""):
"""Read configuration from a .rc file.
`filename` is a file name to read.
+ Returns True or False, whether the file could be read.
+
"""
self.attempted_config_files.append(filename)
- cp = HandyConfigParser()
+ cp = HandyConfigParser(section_prefix)
files_read = cp.read(filename)
- if files_read is not None: # return value changed in 2.4
- self.config_files.extend(files_read)
+ if not files_read:
+ return False
+
+ self.config_files.extend(files_read)
for option_spec in self.CONFIG_FILE_OPTIONS:
- self.set_attr_from_config_option(cp, *option_spec)
+ self._set_attr_from_config_option(cp, *option_spec)
# [paths] is special
if cp.has_section('paths'):
for option in cp.options('paths'):
self.paths[option] = cp.getlist('paths', option)
+ # plugins can have options
+ for plugin in self.plugins:
+ if cp.has_section(plugin):
+ self.plugin_options[plugin] = cp.get_section(plugin)
+
+ return True
+
CONFIG_FILE_OPTIONS = [
+ # These are *args for _set_attr_from_config_option:
+ # (attr, where, type_="")
+ #
+ # attr is the attribute to set on the CoverageConfig object.
+ # where is the section:name to read from the configuration file.
+ # type_ is the optional type to apply, by using .getTYPE to read the
+ # configuration value from the file.
+
# [run]
('branch', 'run:branch', 'boolean'),
('coroutine', 'run:coroutine'),
('cover_pylib', 'run:cover_pylib', 'boolean'),
('data_file', 'run:data_file'),
('debug', 'run:debug', 'list'),
+ ('plugins', 'run:plugins', 'list'),
('include', 'run:include', 'list'),
('omit', 'run:omit', 'list'),
('parallel', 'run:parallel', 'boolean'),
@@ -207,9 +265,13 @@ class CoverageConfig(object):
('xml_output', 'xml:output'),
]
- def set_attr_from_config_option(self, cp, attr, where, type_=''):
+ def _set_attr_from_config_option(self, cp, attr, where, type_=''):
"""Set an attribute on self if it exists in the ConfigParser."""
section, option = where.split(":")
if cp.has_option(section, option):
method = getattr(cp, 'get'+type_)
setattr(self, attr, method(section, option))
+
+ def get_plugin_options(self, plugin):
+ """Get a dictionary of options for the plugin named `plugin`."""
+ return self.plugin_options.get(plugin, {})
diff --git a/coverage/control.py b/coverage/control.py
index d5e2c6f8..86a2ae23 100644
--- a/coverage/control.py
+++ b/coverage/control.py
@@ -1,14 +1,15 @@
"""Core control stuff for Coverage."""
-import atexit, os, random, socket, sys
+import atexit, os, platform, random, socket, sys
from coverage.annotate import AnnotateReporter
from coverage.backward import string_class, iitems
-from coverage.codeunit import code_unit_factory, CodeUnit
+from coverage.codeunit import code_unit_factory, CodeUnit, PythonCodeUnit
from coverage.collector import Collector
from coverage.config import CoverageConfig
from coverage.data import CoverageData
from coverage.debug import DebugControl
+from coverage.plugin import Plugins, plugin_implements
from coverage.files import FileLocator, TreeMatcher, FnmatchMatcher
from coverage.files import PathAliases, find_python_files, prep_patterns
from coverage.html import HtmlReporter
@@ -18,6 +19,7 @@ from coverage.results import Analysis, Numbers
from coverage.summary import SummaryReporter
from coverage.xmlreport import XmlReporter
+
# Pypy has some unusual stuff in the "stdlib". Consider those locations
# when deciding where the stdlib is.
try:
@@ -26,14 +28,14 @@ except ImportError:
_structseq = None
-class coverage(object):
+class Coverage(object):
"""Programmatic access to coverage.py.
To use::
from coverage import coverage
- cov = coverage()
+ cov = Coverage()
cov.start()
#.. call your code ..
cov.stop()
@@ -43,7 +45,7 @@ class coverage(object):
def __init__(self, data_file=None, data_suffix=None, cover_pylib=None,
auto_data=False, timid=None, branch=None, config_file=True,
source=None, omit=None, include=None, debug=None,
- debug_file=None, coroutine=None):
+ debug_file=None, coroutine=None, plugins=None):
"""
`data_file` is the base name of the data file to use, defaulting to
".coverage". `data_suffix` is appended (with a dot) to `data_file` to
@@ -85,7 +87,9 @@ class coverage(object):
`coroutine` is a string indicating the coroutining library being used
in the measured code. Without this, coverage.py will get incorrect
results. Valid strings are "greenlet", "eventlet", or "gevent", which
- are all equivalent.
+ are all equivalent. TODO: really?
+
+ `plugins` TODO.
"""
from coverage import __version__
@@ -97,17 +101,22 @@ class coverage(object):
# 1: defaults:
self.config = CoverageConfig()
- # 2: from the coveragerc file:
+ # 2: from the .coveragerc or setup.cfg file:
if config_file:
+ did_read_rc = should_read_setupcfg = False
if config_file is True:
config_file = ".coveragerc"
+ should_read_setupcfg = True
try:
- self.config.from_file(config_file)
+ did_read_rc = self.config.from_file(config_file)
except ValueError as err:
raise CoverageException(
"Couldn't read config file %s: %s" % (config_file, err)
)
+ if not did_read_rc and should_read_setupcfg:
+ self.config.from_file("setup.cfg", section_prefix="coverage:")
+
# 3: from environment variables:
self.config.from_environment('COVERAGE_OPTIONS')
env_data_file = os.environ.get('COVERAGE_FILE')
@@ -119,12 +128,21 @@ class coverage(object):
data_file=data_file, cover_pylib=cover_pylib, timid=timid,
branch=branch, parallel=bool_or_none(data_suffix),
source=source, omit=omit, include=include, debug=debug,
- coroutine=coroutine,
+ coroutine=coroutine, plugins=plugins,
)
# Create and configure the debugging controller.
self.debug = DebugControl(self.config.debug, debug_file or sys.stderr)
+ # Load plugins
+ self.plugins = Plugins.load_plugins(self.config.plugins, self.config)
+
+ self.trace_judges = []
+ for plugin in self.plugins:
+ if plugin_implements(plugin, "trace_judge"):
+ self.trace_judges.append(plugin)
+ self.trace_judges.append(None) # The Python case.
+
self.auto_data = auto_data
# _exclude_re is a dict mapping exclusion list names to compiled
@@ -147,8 +165,11 @@ class coverage(object):
self.include = prep_patterns(self.config.include)
self.collector = Collector(
- self._should_trace, timid=self.config.timid,
- branch=self.config.branch, warn=self._warn,
+ should_trace=self._should_trace,
+ check_include=self._tracing_check_include_omit_etc,
+ timid=self.config.timid,
+ branch=self.config.branch,
+ warn=self._warn,
coroutine=self.config.coroutine,
)
@@ -175,18 +196,16 @@ class coverage(object):
)
# The dirs for files considered "installed with the interpreter".
- self.pylib_dirs = []
+ self.pylib_dirs = set()
if not self.config.cover_pylib:
# Look at where some standard modules are located. That's the
# indication for "installed with the interpreter". In some
# environments (virtualenv, for example), these modules may be
# spread across a few locations. Look at all the candidate modules
# we've imported, and take all the different ones.
- for m in (atexit, os, random, socket, _structseq):
+ for m in (atexit, os, platform, random, socket, _structseq):
if m is not None and hasattr(m, "__file__"):
- m_dir = self._canonical_dir(m)
- if m_dir not in self.pylib_dirs:
- self.pylib_dirs.append(m_dir)
+ self.pylib_dirs.add(self._canonical_dir(m))
# To avoid tracing the coverage code itself, we skip anything located
# where we are.
@@ -214,7 +233,8 @@ class coverage(object):
def _canonical_dir(self, morf):
"""Return the canonical directory of the module or file `morf`."""
- return os.path.split(CodeUnit(morf, self.file_locator).filename)[0]
+ morf_filename = PythonCodeUnit(morf, self.file_locator).filename
+ return os.path.split(morf_filename)[0]
def _source_for_file(self, filename):
"""Return the source file for `filename`."""
@@ -231,22 +251,14 @@ class coverage(object):
This function is called from the trace function. As each new file name
is encountered, this function determines whether it is traced or not.
- Returns a pair of values: the first indicates whether the file should
- be traced: it's a canonicalized filename if it should be traced, None
- if it should not. The second value is a string, the resason for the
- decision.
+ Returns a FileDisposition object.
"""
- if not filename:
- # Empty string is pretty useless
- return None, "empty string isn't a filename"
-
- if filename.startswith('<'):
- # Lots of non-file execution is represented with artificial
- # filenames like "<string>", "<doctest readme.txt[0]>", or
- # "<exec_function>". Don't ever trace these executions, since we
- # can't do anything with the data later anyway.
- return None, "not a real filename"
+ disp = FileDisposition(filename)
+ def nope(disp, reason):
+ disp.trace = False
+ disp.reason = reason
+ return disp
self._check_for_packages()
@@ -260,53 +272,107 @@ class coverage(object):
if dunder_file:
filename = self._source_for_file(dunder_file)
+ if not filename:
+ # Empty string is pretty useless
+ return nope(disp, "empty string isn't a filename")
+
+ if filename.startswith('memory:'):
+ return nope(disp, "memory isn't traceable")
+
+ if filename.startswith('<'):
+ # Lots of non-file execution is represented with artificial
+ # filenames like "<string>", "<doctest readme.txt[0]>", or
+ # "<exec_function>". Don't ever trace these executions, since we
+ # can't do anything with the data later anyway.
+ return nope(disp, "not a real filename")
+
# Jython reports the .class file to the tracer, use the source file.
if filename.endswith("$py.class"):
filename = filename[:-9] + ".py"
canonical = self.file_locator.canonical_filename(filename)
+ disp.canonical_filename = canonical
+ # Try the plugins, see if they have an opinion about the file.
+ for plugin in self.trace_judges:
+ if plugin:
+ plugin.trace_judge(disp)
+ else:
+ disp.trace = True
+ disp.source_filename = canonical
+ if disp.trace:
+ disp.plugin = plugin
+
+ if disp.check_filters:
+ reason = self._check_include_omit_etc(disp.source_filename)
+ if reason:
+ nope(disp, reason)
+
+ return disp
+
+ return nope(disp, "no plugin found") # TODO: a test that causes this.
+
+ def _check_include_omit_etc(self, filename):
+ """Check a filename against the include, omit, etc, rules.
+
+ Returns a string or None. String means, don't trace, and is the reason
+ why. None means no reason found to not trace.
+
+ """
# If the user specified source or include, then that's authoritative
# about the outer bound of what to measure and we don't have to apply
# any canned exclusions. If they didn't, then we have to exclude the
# stdlib and coverage.py directories.
if self.source_match:
- if not self.source_match.match(canonical):
- return None, "falls outside the --source trees"
+ if not self.source_match.match(filename):
+ return "falls outside the --source trees"
elif self.include_match:
- if not self.include_match.match(canonical):
- return None, "falls outside the --include trees"
+ if not self.include_match.match(filename):
+ return "falls outside the --include trees"
else:
# If we aren't supposed to trace installed code, then check if this
# is near the Python standard library and skip it if so.
- if self.pylib_match and self.pylib_match.match(canonical):
- return None, "is in the stdlib"
+ if self.pylib_match and self.pylib_match.match(filename):
+ return "is in the stdlib"
# We exclude the coverage code itself, since a little of it will be
# measured otherwise.
- if self.cover_match and self.cover_match.match(canonical):
- return None, "is part of coverage.py"
+ if self.cover_match and self.cover_match.match(filename):
+ return "is part of coverage.py"
# Check the file against the omit pattern.
- if self.omit_match and self.omit_match.match(canonical):
- return None, "is inside an --omit pattern"
+ if self.omit_match and self.omit_match.match(filename):
+ return "is inside an --omit pattern"
- return canonical, "because we love you"
+ # No reason found to skip this file.
+ return None
def _should_trace(self, filename, frame):
"""Decide whether to trace execution in `filename`.
- Calls `_should_trace_with_reason`, and returns just the decision.
+ Calls `_should_trace_with_reason`, and returns the FileDisposition.
"""
- canonical, reason = self._should_trace_with_reason(filename, frame)
+ disp = self._should_trace_with_reason(filename, frame)
if self.debug.should('trace'):
- if not canonical:
- msg = "Not tracing %r: %s" % (filename, reason)
- else:
+ self.debug.write(disp.debug_message())
+ return disp
+
+ def _tracing_check_include_omit_etc(self, filename):
+ """Check a filename against the include, omit, etc, rules, and say so.
+
+ Returns a boolean: True if the file should be traced, False if not.
+
+ """
+ reason = self._check_include_omit_etc(filename)
+ if self.debug.should('trace'):
+ if not reason:
msg = "Tracing %r" % (filename,)
+ else:
+ msg = "Not tracing %r: %s" % (filename, reason)
self.debug.write(msg)
- return canonical
+
+ return not reason
def _warn(self, msg):
"""Use `msg` as a warning."""
@@ -524,8 +590,10 @@ class coverage(object):
if not self._measured:
return
+ # TODO: seems like this parallel structure is getting kinda old...
self.data.add_line_data(self.collector.get_line_data())
self.data.add_arc_data(self.collector.get_arc_data())
+ self.data.add_plugin_data(self.collector.get_plugin_data())
self.collector.reset()
# If there are still entries in the source_pkgs list, then we never
@@ -591,9 +659,17 @@ class coverage(object):
Returns an `Analysis` object.
"""
+ def get_plugin(filename):
+ """For code_unit_factory to use to find the plugin for a file."""
+ plugin = None
+ plugin_name = self.data.plugin_data().get(filename)
+ if plugin_name:
+ plugin = self.plugins.get(plugin_name)
+ return plugin
+
self._harvest_data()
if not isinstance(it, CodeUnit):
- it = code_unit_factory(it, self.file_locator)[0]
+ it = code_unit_factory(it, self.file_locator, get_plugin)[0]
return Analysis(self, it)
@@ -692,6 +768,13 @@ class coverage(object):
if self.config.xml_output == '-':
outfile = sys.stdout
else:
+ # Ensure that the output directory is created; done here
+ # because this report pre-opens the output file.
+ # HTMLReport does this using the Report plumbing because
+ # its task is more complex, being multiple files.
+ output_dir = os.path.dirname(self.config.xml_output)
+ if output_dir and not os.path.isdir(output_dir):
+ os.makedirs(output_dir)
outfile = open(self.config.xml_output, "w")
file_to_close = outfile
try:
@@ -710,7 +793,6 @@ class coverage(object):
"""Return a list of (key, value) pairs showing internal information."""
import coverage as covmod
- import platform, re
try:
implementation = platform.python_implementation()
@@ -732,10 +814,10 @@ class coverage(object):
('executable', sys.executable),
('cwd', os.getcwd()),
('path', sys.path),
- ('environment', sorted([
+ ('environment', sorted(
("%s = %s" % (k, v)) for k, v in iitems(os.environ)
- if re.search(r"^COV|^PY", k)
- ])),
+ if k.startswith(("COV", "PY"))
+ )),
('command_line', " ".join(getattr(sys, 'argv', ['???']))),
]
if self.source_match:
@@ -752,6 +834,26 @@ class coverage(object):
return info
+class FileDisposition(object):
+ """A simple object for noting a number of details of files to trace."""
+ def __init__(self, original_filename):
+ self.original_filename = original_filename
+ self.canonical_filename = original_filename
+ self.source_filename = None
+ self.check_filters = True
+ self.trace = False
+ self.reason = ""
+ self.plugin = None
+
+ def debug_message(self):
+ """Produce a debugging message explaining the outcome."""
+ if self.trace:
+ msg = "Tracing %r" % (self.original_filename,)
+ else:
+ msg = "Not tracing %r: %s" % (self.original_filename, self.reason)
+ return msg
+
+
def process_startup():
"""Call this at Python startup to perhaps measure coverage.
@@ -774,7 +876,7 @@ def process_startup():
"""
cps = os.environ.get("COVERAGE_PROCESS_START")
if cps:
- cov = coverage(config_file=cps, auto_data=True)
+ cov = Coverage(config_file=cps, auto_data=True)
cov.start()
cov._warn_no_data = False
cov._warn_unimported_source = False
diff --git a/coverage/data.py b/coverage/data.py
index 042b6405..e220a364 100644
--- a/coverage/data.py
+++ b/coverage/data.py
@@ -21,6 +21,11 @@ class CoverageData(object):
* arcs: a dict mapping filenames to sorted lists of line number pairs:
{ 'file1': [(17,23), (17,25), (25,26)], ... }
+ * plugins: a dict mapping filenames to plugin names:
+ { 'file1': "django.coverage", ... }
+ # TODO: how to handle the difference between a plugin module
+ # name, and the class in the module?
+
"""
def __init__(self, basename=None, collector=None, debug=None):
@@ -64,6 +69,14 @@ class CoverageData(object):
#
self.arcs = {}
+ # A map from canonical source file name to an plugin module name:
+ #
+ # {
+ # 'filename1.py': 'django.coverage',
+ # ...
+ # }
+ self.plugins = {}
+
def usefile(self, use_file=True):
"""Set whether or not to use a disk file for data."""
self.use_file = use_file
@@ -110,6 +123,9 @@ class CoverageData(object):
(f, sorted(amap.keys())) for f, amap in iitems(self.arcs)
)
+ def plugin_data(self):
+ return self.plugins
+
def write_file(self, filename):
"""Write the coverage data to `filename`."""
@@ -213,6 +229,9 @@ class CoverageData(object):
for filename, arcs in iitems(arc_data):
self.arcs.setdefault(filename, {}).update(arcs)
+ def add_plugin_data(self, plugin_data):
+ self.plugins.update(plugin_data)
+
def touch_file(self, filename):
"""Ensure that `filename` appears in the data, empty if needed."""
self.lines.setdefault(filename, {})
diff --git a/coverage/debug.py b/coverage/debug.py
index 6908383d..6e7af242 100644
--- a/coverage/debug.py
+++ b/coverage/debug.py
@@ -45,7 +45,7 @@ def info_formatter(info):
for label, data in info:
if data == []:
data = "-none-"
- if isinstance(data, (list, tuple)):
+ if isinstance(data, (list, set, tuple)):
prefix = "%*s:" % (label_len, label)
for e in data:
yield "%*s %s" % (label_len+1, prefix, e)
diff --git a/coverage/django.py b/coverage/django.py
new file mode 100644
index 00000000..00f2ed54
--- /dev/null
+++ b/coverage/django.py
@@ -0,0 +1,61 @@
+import sys
+
+
+ALL_TEMPLATE_MAP = {}
+
+def get_line_map(filename):
+ if filename not in ALL_TEMPLATE_MAP:
+ with open(filename) as template_file:
+ template_source = template_file.read()
+ line_lengths = [len(l) for l in template_source.splitlines(True)]
+ ALL_TEMPLATE_MAP[filename] = list(running_sum(line_lengths))
+ return ALL_TEMPLATE_MAP[filename]
+
+def get_line_number(line_map, offset):
+ for lineno, line_offset in enumerate(line_map, start=1):
+ if line_offset >= offset:
+ return lineno
+ return -1
+
+class DjangoTracer(object):
+ def should_trace(self, canonical):
+ return "/django/template/" in canonical
+
+ def source(self, frame):
+ if frame.f_code.co_name != 'render':
+ return None
+ that = frame.f_locals['self']
+ return getattr(that, "source", None)
+
+ def file_name(self, frame):
+ source = self.source(frame)
+ if not source:
+ return None
+ return source[0].name.encode(sys.getfilesystemencoding())
+
+ def line_number_range(self, frame):
+ source = self.source(frame)
+ if not source:
+ return -1, -1
+ filename = source[0].name
+ line_map = get_line_map(filename)
+ start = get_line_number(line_map, source[1][0])
+ end = get_line_number(line_map, source[1][1])
+ if start < 0 or end < 0:
+ return -1, -1
+ return start, end
+
+def running_sum(seq):
+ total = 0
+ for num in seq:
+ total += num
+ yield total
+
+def ppp(obj):
+ ret = []
+ import inspect
+ for name, value in inspect.getmembers(obj):
+ if not callable(value):
+ ret.append("%s=%r" % (name, value))
+ attrs = ", ".join(ret)
+ return "%s: %s" % (obj.__class__, attrs)
diff --git a/coverage/execfile.py b/coverage/execfile.py
index 7b90137a..b7877b6a 100644
--- a/coverage/execfile.py
+++ b/coverage/execfile.py
@@ -1,23 +1,83 @@
"""Execute files of Python code."""
-import imp, marshal, os, sys
+import marshal, os, sys, types
-from coverage.backward import open_source
+from coverage.backward import open_python_source, BUILTINS
+from coverage.backward import PYC_MAGIC_NUMBER, imp, importlib_util_find_spec
from coverage.misc import ExceptionDuringRun, NoCode, NoSource
-try:
- # In Py 2.x, the builtins were in __builtin__
- BUILTINS = sys.modules['__builtin__']
-except KeyError:
- # In Py 3.x, they're in builtins
- BUILTINS = sys.modules['builtins']
+if importlib_util_find_spec:
+ def find_module(modulename):
+ """Find the module named `modulename`.
+ Returns the file path of the module, and the name of the enclosing
+ package.
+ """
+ # pylint: disable=no-member
+ try:
+ spec = importlib_util_find_spec(modulename)
+ except ImportError as err:
+ raise NoSource(str(err))
+ if not spec:
+ raise NoSource("No module named %r" % (modulename,))
+ pathname = spec.origin
+ packagename = spec.name
+ if pathname.endswith("__init__.py"):
+ mod_main = modulename + ".__main__"
+ spec = importlib_util_find_spec(mod_main)
+ if not spec:
+ raise NoSource(
+ "No module named %s; "
+ "%r is a package and cannot be directly executed"
+ % (mod_main, modulename)
+ )
+ pathname = spec.origin
+ packagename = spec.name
+ packagename = packagename.rpartition(".")[0]
+ return pathname, packagename
+else:
+ def find_module(modulename):
+ """Find the module named `modulename`.
+
+ Returns the file path of the module, and the name of the enclosing
+ package.
+ """
+ openfile = None
+ glo, loc = globals(), locals()
+ try:
+ # Search for the module - inside its parent package, if any - using
+ # standard import mechanics.
+ if '.' in modulename:
+ packagename, name = modulename.rsplit('.', 1)
+ package = __import__(packagename, glo, loc, ['__path__'])
+ searchpath = package.__path__
+ else:
+ packagename, name = None, modulename
+ searchpath = None # "top-level search" in imp.find_module()
+ openfile, pathname, _ = imp.find_module(name, searchpath)
-def rsplit1(s, sep):
- """The same as s.rsplit(sep, 1), but works in 2.3"""
- parts = s.split(sep)
- return sep.join(parts[:-1]), parts[-1]
+ # Complain if this is a magic non-file module.
+ if openfile is None and pathname is None:
+ raise NoSource(
+ "module does not live in a file: %r" % modulename
+ )
+
+ # If `modulename` is actually a package, not a mere module, then we
+ # pretend to be Python 2.7 and try running its __main__.py script.
+ if openfile is None:
+ packagename = modulename
+ name = '__main__'
+ package = __import__(packagename, glo, loc, ['__path__'])
+ searchpath = package.__path__
+ openfile, pathname, _ = imp.find_module(name, searchpath)
+ except ImportError as err:
+ raise NoSource(str(err))
+ finally:
+ if openfile:
+ openfile.close()
+
+ return pathname, packagename
def run_python_module(modulename, args):
@@ -28,41 +88,8 @@ def run_python_module(modulename, args):
element naming the module being executed.
"""
- openfile = None
- glo, loc = globals(), locals()
- try:
- # Search for the module - inside its parent package, if any - using
- # standard import mechanics.
- if '.' in modulename:
- packagename, name = rsplit1(modulename, '.')
- package = __import__(packagename, glo, loc, ['__path__'])
- searchpath = package.__path__
- else:
- packagename, name = None, modulename
- searchpath = None # "top-level search" in imp.find_module()
- openfile, pathname, _ = imp.find_module(name, searchpath)
-
- # Complain if this is a magic non-file module.
- if openfile is None and pathname is None:
- raise NoSource(
- "module does not live in a file: %r" % modulename
- )
+ pathname, packagename = find_module(modulename)
- # If `modulename` is actually a package, not a mere module, then we
- # pretend to be Python 2.7 and try running its __main__.py script.
- if openfile is None:
- packagename = modulename
- name = '__main__'
- package = __import__(packagename, glo, loc, ['__path__'])
- searchpath = package.__path__
- openfile, pathname, _ = imp.find_module(name, searchpath)
- except ImportError as err:
- raise NoSource(str(err))
- finally:
- if openfile:
- openfile.close()
-
- # Finally, hand the file off to run_python_file for execution.
pathname = os.path.abspath(pathname)
args[0] = pathname
run_python_file(pathname, args, package=packagename)
@@ -79,7 +106,7 @@ def run_python_file(filename, args, package=None):
"""
# Create a module to serve as __main__
old_main_mod = sys.modules['__main__']
- main_mod = imp.new_module('__main__')
+ main_mod = types.ModuleType('__main__')
sys.modules['__main__'] = main_mod
main_mod.__file__ = filename
if package:
@@ -119,11 +146,12 @@ def run_python_file(filename, args, package=None):
# Restore the old argv and path
sys.argv = old_argv
+
def make_code_from_py(filename):
"""Get source from `filename` and make a code object of it."""
# Open the source file.
try:
- source_file = open_source(filename)
+ source_file = open_python_source(filename)
except IOError:
raise NoSource("No file to run: %r" % filename)
@@ -150,7 +178,7 @@ def make_code_from_pyc(filename):
# First four bytes are a version-specific magic number. It has to
# match or we won't run the file.
magic = fpyc.read(4)
- if magic != imp.get_magic():
+ if magic != PYC_MAGIC_NUMBER:
raise NoCode("Bad magic number in .pyc file")
# Skip the junk in the header that we don't need.
diff --git a/coverage/files.py b/coverage/files.py
index 94388f96..1ed7276e 100644
--- a/coverage/files.py
+++ b/coverage/files.py
@@ -1,7 +1,7 @@
"""File wrangling."""
from coverage.backward import to_string
-from coverage.misc import CoverageException
+from coverage.misc import CoverageException, join_regex
import fnmatch, os, os.path, re, sys
import ntpath, posixpath
@@ -147,7 +147,7 @@ def prep_patterns(patterns):
class TreeMatcher(object):
"""A matcher for files in a tree."""
def __init__(self, directories):
- self.dirs = directories[:]
+ self.dirs = list(directories)
def __repr__(self):
return "<TreeMatcher %r>" % self.dirs
@@ -177,6 +177,17 @@ class FnmatchMatcher(object):
"""A matcher for files by filename pattern."""
def __init__(self, pats):
self.pats = pats[:]
+ # fnmatch is platform-specific. On Windows, it does the Windows thing
+ # of treating / and \ as equivalent. But on other platforms, we need to
+ # take care of that ourselves.
+ fnpats = (fnmatch.translate(p) for p in pats)
+ fnpats = (p.replace(r"\/", r"[\\/]") for p in fnpats)
+ if sys.platform == 'win32':
+ # Windows is also case-insensitive. BTW: the regex docs say that
+ # flags like (?i) have to be at the beginning, but fnmatch puts
+ # them at the end, and have two there seems to work fine.
+ fnpats = (p + "(?i)" for p in fnpats)
+ self.re = re.compile(join_regex(fnpats))
def __repr__(self):
return "<FnmatchMatcher %r>" % self.pats
@@ -187,10 +198,7 @@ class FnmatchMatcher(object):
def match(self, fpath):
"""Does `fpath` match one of our filename patterns?"""
- for pat in self.pats:
- if fnmatch.fnmatch(fpath, pat):
- return True
- return False
+ return self.re.match(fpath) is not None
def sep(s):
diff --git a/coverage/html.py b/coverage/html.py
index d168e351..863d1508 100644
--- a/coverage/html.py
+++ b/coverage/html.py
@@ -5,7 +5,6 @@ import os, re, shutil, sys
import coverage
from coverage.backward import pickle
from coverage.misc import CoverageException, Hasher
-from coverage.phystokens import source_token_lines, source_encoding
from coverage.report import Reporter
from coverage.results import Numbers
from coverage.templite import Templite
@@ -149,9 +148,7 @@ class HtmlReporter(Reporter):
def html_file(self, cu, analysis):
"""Generate an HTML file for one source file."""
- source_file = cu.source_file()
- with source_file:
- source = source_file.read()
+ source = cu.source()
# Find out if the file on disk is already correct.
flat_rootname = cu.flat_rootname()
@@ -167,7 +164,7 @@ class HtmlReporter(Reporter):
# If need be, determine the encoding of the source file. We use it
# later to properly write the HTML.
if sys.version_info < (3, 0):
- encoding = source_encoding(source)
+ encoding = cu.source_encoding()
# Some UTF8 files have the dreaded UTF8 BOM. If so, junk it.
if encoding.startswith("utf-8") and source[:3] == "\xef\xbb\xbf":
source = source[3:]
@@ -187,7 +184,7 @@ class HtmlReporter(Reporter):
lines = []
- for lineno, line in enumerate(source_token_lines(source), start=1):
+ for lineno, line in enumerate(cu.source_token_lines(), start=1):
# Figure out how to mark this line.
line_class = []
annotate_html = ""
@@ -241,7 +238,9 @@ class HtmlReporter(Reporter):
}))
if sys.version_info < (3, 0):
- html = html.decode(encoding)
+ # In theory, all the characters in the source can be decoded, but
+ # strange things happen, so use 'replace' to keep errors at bay.
+ html = html.decode(encoding, 'replace')
html_filename = flat_rootname + ".html"
html_path = os.path.join(self.directory, html_filename)
diff --git a/coverage/misc.py b/coverage/misc.py
index c88d4ecd..6962ae32 100644
--- a/coverage/misc.py
+++ b/coverage/misc.py
@@ -1,10 +1,10 @@
"""Miscellaneous stuff for Coverage."""
import errno
+import hashlib
import inspect
import os
-from coverage.backward import md5
from coverage.backward import string_class, to_bytes
@@ -86,12 +86,7 @@ def bool_or_none(b):
def join_regex(regexes):
"""Combine a list of regexes into one that matches any of them."""
- if len(regexes) > 1:
- return "|".join("(%s)" % r for r in regexes)
- elif regexes:
- return regexes[0]
- else:
- return ""
+ return "|".join("(?:%s)" % r for r in regexes)
def file_be_gone(path):
@@ -106,7 +101,7 @@ def file_be_gone(path):
class Hasher(object):
"""Hashes Python data into md5."""
def __init__(self):
- self.md5 = md5()
+ self.md5 = hashlib.md5()
def update(self, v):
"""Add `v` to the hash, recursively if needed."""
diff --git a/coverage/parser.py b/coverage/parser.py
index de6590aa..c5e95baa 100644
--- a/coverage/parser.py
+++ b/coverage/parser.py
@@ -3,14 +3,31 @@
import collections, dis, re, token, tokenize
from coverage.backward import StringIO
-from coverage.backward import open_source, range # pylint: disable=W0622
-from coverage.backward import bytes_to_ints
+from coverage.backward import range # pylint: disable=W0622
+from coverage.backward import bytes_to_ints, open_python_source
from coverage.bytecode import ByteCodes, CodeObjects
from coverage.misc import nice_pair, expensive, join_regex
from coverage.misc import CoverageException, NoSource, NotPython
class CodeParser(object):
+ """
+ Base class for any code parser.
+ """
+ def translate_lines(self, lines):
+ return set(lines)
+
+ def translate_arcs(self, arcs):
+ return arcs
+
+ def exit_counts(self):
+ return {}
+
+ def arcs(self):
+ return []
+
+
+class PythonParser(CodeParser):
"""Parse code to find executable lines, excluded lines, etc."""
def __init__(self, text=None, filename=None, exclude=None):
@@ -20,12 +37,12 @@ class CodeParser(object):
`exclude`, a regex.
"""
- assert text or filename, "CodeParser needs either text or filename"
+ assert text or filename, "PythonParser needs either text or filename"
self.filename = filename or "<code>"
self.text = text
if not self.text:
try:
- with open_source(self.filename) as sourcef:
+ with open_python_source(self.filename) as sourcef:
self.text = sourcef.read()
except IOError as err:
raise NoSource(
@@ -137,9 +154,8 @@ class CodeParser(object):
# We're at the end of a line, and we've ended on a
# different line than the first line of the statement,
# so record a multi-line range.
- rng = (first_line, elineno)
for l in range(first_line, elineno+1):
- self.multiline[l] = rng
+ self.multiline[l] = first_line
first_line = None
if ttext.strip() and toktype != tokenize.COMMENT:
@@ -163,33 +179,29 @@ class CodeParser(object):
def first_line(self, line):
"""Return the first line number of the statement including `line`."""
- rng = self.multiline.get(line)
- if rng:
- first_line = rng[0]
+ first_line = self.multiline.get(line)
+ if first_line:
+ return first_line
else:
- first_line = line
- return first_line
+ return line
- def first_lines(self, lines, *ignores):
+ def first_lines(self, lines):
"""Map the line numbers in `lines` to the correct first line of the
statement.
- Skip any line mentioned in any of the sequences in `ignores`.
-
Returns a set of the first lines.
"""
- ignore = set()
- for ign in ignores:
- ignore.update(ign)
- lset = set()
- for l in lines:
- if l in ignore:
- continue
- new_l = self.first_line(l)
- if new_l not in ignore:
- lset.add(new_l)
- return lset
+ return set(self.first_line(l) for l in lines)
+
+ def translate_lines(self, lines):
+ return self.first_lines(lines)
+
+ def translate_arcs(self, arcs):
+ return [
+ (self.first_line(a), self.first_line(b))
+ for (a, b) in arcs
+ ]
def parse_source(self):
"""Parse source text to find executable lines, excluded lines, etc.
@@ -211,11 +223,12 @@ class CodeParser(object):
)
excluded_lines = self.first_lines(self.excluded)
- lines = self.first_lines(
- self.statement_starts,
- excluded_lines,
- self.docstrings
- )
+ ignore = set()
+ ignore.update(excluded_lines)
+ ignore.update(self.docstrings)
+ starts = self.statement_starts - ignore
+ lines = self.first_lines(starts)
+ lines -= ignore
return lines, excluded_lines
@@ -328,7 +341,7 @@ class ByteParser(object):
else:
if not text:
assert filename, "If no code or text, need a filename"
- with open_source(filename) as sourcef:
+ with open_python_source(filename) as sourcef:
text = sourcef.read()
self.text = text
diff --git a/coverage/phystokens.py b/coverage/phystokens.py
index e79ce01f..867388f7 100644
--- a/coverage/phystokens.py
+++ b/coverage/phystokens.py
@@ -120,7 +120,7 @@ def source_encoding(source):
# This is mostly code adapted from Py3.2's tokenize module.
- cookie_re = re.compile(r"coding[:=]\s*([-\w.]+)")
+ cookie_re = re.compile(r"^\s*#.*coding[:=]\s*([-\w.]+)")
# Do this so the detect_encode code we copied will work.
readline = iter(source.splitlines(True)).next
diff --git a/coverage/plugin.py b/coverage/plugin.py
new file mode 100644
index 00000000..35be41a9
--- /dev/null
+++ b/coverage/plugin.py
@@ -0,0 +1,108 @@
+"""Plugin management for coverage.py"""
+
+import sys
+
+
+class CoveragePlugin(object):
+ """Base class for coverage.py plugins."""
+ def __init__(self, options):
+ self.options = options
+
+ def trace_judge(self, disposition):
+ """Decide whether to trace this file with this plugin.
+
+ Set disposition.trace to True if this plugin should trace this file.
+ May also set other attributes in `disposition`.
+
+ """
+ return None
+
+ def source_file_name(self, filename):
+ """Return the source name for a given Python filename.
+
+ Can return None if tracing shouldn't continue.
+
+ """
+ return filename
+
+ def dynamic_source_file_name(self):
+ """Returns a callable that can return a source name for a frame.
+
+ The callable should take a filename and a frame, and return either a
+ filename or None:
+
+ def dynamic_source_filename_func(filename, frame)
+
+ Can return None if dynamic filenames aren't needed.
+
+ """
+ return None
+
+ def code_unit_class(self, morf):
+ """Return the CodeUnit class to use for a module or filename."""
+ return None
+
+
+class Plugins(object):
+ """The currently loaded collection of coverage.py plugins."""
+
+ def __init__(self):
+ self.order = []
+ self.names = {}
+
+ @classmethod
+ def load_plugins(cls, modules, config):
+ """Load plugins from `modules`.
+
+ Returns a list of loaded and configured plugins.
+
+ """
+ plugins = cls()
+
+ for module in modules:
+ __import__(module)
+ mod = sys.modules[module]
+
+ plugin_class = getattr(mod, "Plugin", None)
+ if plugin_class:
+ options = config.get_plugin_options(module)
+ plugin = plugin_class(options)
+ plugin.__name__ = module
+ plugins.order.append(plugin)
+ plugins.names[module] = plugin
+
+ return plugins
+
+ def __iter__(self):
+ return iter(self.order)
+
+ def get(self, module):
+ return self.names[module]
+
+
+def overrides(obj, method_name, base_class):
+ """Does `obj` override the `method_name` it got from `base_class`?
+
+ Determine if `obj` implements the method called `method_name`, which it
+ inherited from `base_class`.
+
+ Returns a boolean.
+
+ """
+ klass = obj.__class__
+ klass_func = getattr(klass, method_name)
+ base_func = getattr(base_class, method_name)
+
+ # Python 2/3 compatibility: Python 2 returns an instancemethod object, the
+ # function is the .im_func attribute. Python 3 returns a plain function
+ # object already.
+ if sys.version_info < (3, 0):
+ klass_func = klass_func.im_func
+ base_func = base_func.im_func
+
+ return klass_func is not base_func
+
+
+def plugin_implements(obj, method_name):
+ """Does the plugin `obj` implement `method_name`?"""
+ return overrides(obj, method_name, CoveragePlugin)
diff --git a/coverage/pytracer.py b/coverage/pytracer.py
new file mode 100644
index 00000000..7563ae11
--- /dev/null
+++ b/coverage/pytracer.py
@@ -0,0 +1,163 @@
+"""Raw data collector for Coverage."""
+
+import sys
+
+
+class PyTracer(object):
+ """Python implementation of the raw data tracer."""
+
+ # Because of poor implementations of trace-function-manipulating tools,
+ # the Python trace function must be kept very simple. In particular, there
+ # must be only one function ever set as the trace function, both through
+ # sys.settrace, and as the return value from the trace function. Put
+ # another way, the trace function must always return itself. It cannot
+ # swap in other functions, or return None to avoid tracing a particular
+ # frame.
+ #
+ # The trace manipulator that introduced this restriction is DecoratorTools,
+ # which sets a trace function, and then later restores the pre-existing one
+ # by calling sys.settrace with a function it found in the current frame.
+ #
+ # Systems that use DecoratorTools (or similar trace manipulations) must use
+ # PyTracer to get accurate results. The command-line --timid argument is
+ # used to force the use of this tracer.
+
+ def __init__(self):
+ # Attributes set from the collector:
+ self.data = None
+ self.arcs = False
+ self.should_trace = None
+ self.should_trace_cache = None
+ self.warn = None
+ self.plugin_data = None
+ # The threading module to use, if any.
+ self.threading = None
+
+ self.plugin = []
+ self.cur_file_dict = []
+ self.last_line = [0]
+
+ self.data_stack = []
+ self.last_exc_back = None
+ self.last_exc_firstlineno = 0
+ self.thread = None
+ self.stopped = False
+
+ def __repr__(self):
+ return "<PyTracer at 0x{0:0x}: {1} lines in {2} files>".format(
+ id(self),
+ sum(len(v) for v in self.data.values()),
+ len(self.data),
+ )
+
+ def _trace(self, frame, event, arg_unused):
+ """The trace function passed to sys.settrace."""
+
+ if self.stopped:
+ return
+
+ if self.last_exc_back: # TODO: bring this up to speed
+ if frame == self.last_exc_back:
+ # Someone forgot a return event.
+ if self.arcs and self.cur_file_dict:
+ pair = (self.last_line, -self.last_exc_firstlineno)
+ self.cur_file_dict[pair] = None
+ self.plugin, self.cur_file_dict, self.last_line = (
+ self.data_stack.pop()
+ )
+ self.last_exc_back = None
+
+ if event == 'call':
+ # Entering a new function context. Decide if we should trace
+ # in this file.
+ self.data_stack.append(
+ (self.plugin, self.cur_file_dict, self.last_line)
+ )
+ filename = frame.f_code.co_filename
+ disp = self.should_trace_cache.get(filename)
+ if disp is None:
+ disp = self.should_trace(filename, frame)
+ self.should_trace_cache[filename] = disp
+
+ self.plugin = None
+ self.cur_file_dict = None
+ if disp.trace:
+ tracename = disp.source_filename
+ if disp.plugin:
+ dyn_func = disp.plugin.dynamic_source_file_name()
+ if dyn_func:
+ tracename = dyn_func(tracename, frame)
+ if tracename:
+ if not self.check_include(tracename):
+ tracename = None
+ else:
+ tracename = None
+ if tracename:
+ if tracename not in self.data:
+ self.data[tracename] = {}
+ if disp.plugin:
+ self.plugin_data[tracename] = disp.plugin.__name__
+ self.cur_file_dict = self.data[tracename]
+ self.plugin = disp.plugin
+ # Set the last_line to -1 because the next arc will be entering a
+ # code block, indicated by (-1, n).
+ self.last_line = -1
+ elif event == 'line':
+ # Record an executed line.
+ if self.plugin:
+ lineno_from, lineno_to = self.plugin.line_number_range(frame)
+ else:
+ lineno_from, lineno_to = frame.f_lineno, frame.f_lineno
+ if lineno_from != -1:
+ if self.cur_file_dict is not None:
+ if self.arcs:
+ self.cur_file_dict[
+ (self.last_line, lineno_from)
+ ] = None
+ else:
+ for lineno in range(lineno_from, lineno_to+1):
+ self.cur_file_dict[lineno] = None
+ self.last_line = lineno_to
+ 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
+ # Leaving this function, pop the filename stack.
+ self.plugin, self.cur_file_dict, self.last_line = (
+ self.data_stack.pop()
+ )
+ elif event == 'exception':
+ self.last_exc_back = frame.f_back
+ self.last_exc_firstlineno = frame.f_code.co_firstlineno
+ return self._trace
+
+ def start(self):
+ """Start this Tracer.
+
+ Return a Python function suitable for use with sys.settrace().
+
+ """
+ if self.threading:
+ self.thread = self.threading.currentThread()
+ sys.settrace(self._trace)
+ return self._trace
+
+ def stop(self):
+ """Stop this Tracer."""
+ self.stopped = True
+ if self.threading and self.thread != self.threading.currentThread():
+ # Called on a different thread than started us: we can't unhook
+ # ourseves, but we've set the flag that we should stop, so we won't
+ # do any more tracing.
+ return
+
+ if self.warn:
+ if sys.gettrace() != self._trace:
+ msg = "Trace function changed, measurement is likely wrong: %r"
+ self.warn(msg % (sys.gettrace(),))
+
+ sys.settrace(None)
+
+ def get_stats(self):
+ """Return a dictionary of statistics, or None."""
+ return None
diff --git a/coverage/report.py b/coverage/report.py
index 34f44422..b93749c8 100644
--- a/coverage/report.py
+++ b/coverage/report.py
@@ -1,8 +1,8 @@
"""Reporter foundation for Coverage."""
-import fnmatch, os
+import os
from coverage.codeunit import code_unit_factory
-from coverage.files import prep_patterns
+from coverage.files import prep_patterns, FnmatchMatcher
from coverage.misc import CoverageException, NoSource, NotPython
class Reporter(object):
@@ -33,26 +33,24 @@ class Reporter(object):
"""
morfs = morfs or self.coverage.data.measured_files()
file_locator = self.coverage.file_locator
- self.code_units = code_unit_factory(morfs, file_locator)
+ get_plugin = self.coverage.data.plugin_data().get
+ self.code_units = code_unit_factory(morfs, file_locator, get_plugin)
if self.config.include:
patterns = prep_patterns(self.config.include)
+ matcher = FnmatchMatcher(patterns)
filtered = []
for cu in self.code_units:
- for pattern in patterns:
- if fnmatch.fnmatch(cu.filename, pattern):
- filtered.append(cu)
- break
+ if matcher.match(cu.filename):
+ filtered.append(cu)
self.code_units = filtered
if self.config.omit:
patterns = prep_patterns(self.config.omit)
+ matcher = FnmatchMatcher(patterns)
filtered = []
for cu in self.code_units:
- for pattern in patterns:
- if fnmatch.fnmatch(cu.filename, pattern):
- break
- else:
+ if not matcher.match(cu.filename):
filtered.append(cu)
self.code_units = filtered
diff --git a/coverage/results.py b/coverage/results.py
index 0576ae1f..6cbcbfc8 100644
--- a/coverage/results.py
+++ b/coverage/results.py
@@ -1,11 +1,9 @@
"""Results of coverage measurement."""
import collections
-import os
from coverage.backward import iitems
-from coverage.misc import format_lines, join_regex, NoSource
-from coverage.parser import CodeParser
+from coverage.misc import format_lines, join_regex
class Analysis(object):
@@ -16,18 +14,15 @@ class Analysis(object):
self.code_unit = code_unit
self.filename = self.code_unit.filename
- actual_filename, source = self.find_source(self.filename)
-
- self.parser = CodeParser(
- text=source, filename=actual_filename,
+ self.parser = code_unit.get_parser(
exclude=self.coverage._exclude_regex('exclude')
)
self.statements, self.excluded = self.parser.parse_source()
# Identify missing statements.
executed = self.coverage.data.executed_lines(self.filename)
- exec1 = self.parser.first_lines(executed)
- self.missing = self.statements - exec1
+ executed = self.parser.translate_lines(executed)
+ self.missing = self.statements - executed
if self.coverage.data.has_arcs():
self.no_branch = self.parser.lines_matching(
@@ -54,44 +49,6 @@ class Analysis(object):
n_missing_branches=n_missing_branches,
)
- def find_source(self, filename):
- """Find the source for `filename`.
-
- Returns two values: the actual filename, and the source.
-
- The source returned depends on which of these cases holds:
-
- * The filename seems to be a non-source file: returns None
-
- * The filename is a source file, and actually exists: returns None.
-
- * The filename is a source file, and is in a zip file or egg:
- returns the source.
-
- * The filename is a source file, but couldn't be found: raises
- `NoSource`.
-
- """
- source = None
-
- base, ext = os.path.splitext(filename)
- TRY_EXTS = {
- '.py': ['.py', '.pyw'],
- '.pyw': ['.pyw'],
- }
- try_exts = TRY_EXTS.get(ext)
- if not try_exts:
- return filename, None
-
- for try_ext in try_exts:
- try_filename = base + try_ext
- if os.path.exists(try_filename):
- return try_filename, None
- source = self.coverage.file_locator.get_zip_data(try_filename)
- if source:
- return try_filename, source
- raise NoSource("No source for code: '%s'" % filename)
-
def missing_formatted(self):
"""The missing line numbers, formatted nicely.
@@ -112,8 +69,7 @@ class Analysis(object):
def arcs_executed(self):
"""Returns a sorted list of the arcs actually executed in the code."""
executed = self.coverage.data.executed_arcs(self.filename)
- m2fl = self.parser.first_line
- executed = ((m2fl(l1), m2fl(l2)) for (l1,l2) in executed)
+ executed = self.parser.translate_arcs(executed)
return sorted(executed)
def arcs_missing(self):
@@ -127,6 +83,23 @@ class Analysis(object):
)
return sorted(missing)
+ def arcs_missing_formatted(self):
+ """ The missing branch arcs, formatted nicely.
+
+ Returns a string like "1->2, 1->3, 16->20". Omits any mention of
+ missing lines, so if line 17 is missing, then 16->17 won't be included.
+
+ """
+ arcs = self.missing_branch_arcs()
+ missing = self.missing
+ line_exits = sorted(iitems(arcs))
+ pairs = []
+ for line, exits in line_exits:
+ for ex in sorted(exits):
+ if line not in missing and ex not in missing:
+ pairs.append('%d->%d' % (line, ex))
+ return ', '.join(pairs)
+
def arcs_unpredicted(self):
"""Returns a sorted list of the executed arcs missing from the code."""
possible = self.arc_possibilities()
diff --git a/coverage/summary.py b/coverage/summary.py
index c99c5303..9d31c226 100644
--- a/coverage/summary.py
+++ b/coverage/summary.py
@@ -59,12 +59,19 @@ class SummaryReporter(Reporter):
args += (nums.n_branches, nums.n_missing_branches)
args += (nums.pc_covered_str,)
if self.config.show_missing:
- args += (analysis.missing_formatted(),)
+ missing_fmtd = analysis.missing_formatted()
+ if self.branches:
+ branches_fmtd = analysis.arcs_missing_formatted()
+ if branches_fmtd:
+ if missing_fmtd:
+ missing_fmtd += ", "
+ missing_fmtd += branches_fmtd
+ args += (missing_fmtd,)
outfile.write(fmt_coverage % args)
total += nums
except KeyboardInterrupt: # pragma: not covered
raise
- except:
+ except Exception:
report_it = not self.config.ignore_errors
if report_it:
typ, msg = sys.exc_info()[:2]
diff --git a/coverage/templite.py b/coverage/templite.py
index 1829aa82..53824e08 100644
--- a/coverage/templite.py
+++ b/coverage/templite.py
@@ -5,47 +5,55 @@
import re
+class TempliteSyntaxError(ValueError):
+ """Raised when a template has a syntax error."""
+ pass
+
+
class CodeBuilder(object):
"""Build source code conveniently."""
def __init__(self, indent=0):
self.code = []
- self.indent_amount = indent
+ self.indent_level = indent
+
+ def __str__(self):
+ return "".join(str(c) for c in self.code)
def add_line(self, line):
"""Add a line of source to the code.
- Don't include indentations or newlines.
+ Indentation and newline will be added for you, don't provide them.
"""
- self.code.append(" " * self.indent_amount)
- self.code.append(line)
- self.code.append("\n")
+ self.code.extend([" " * self.indent_level, line, "\n"])
def add_section(self):
"""Add a section, a sub-CodeBuilder."""
- sect = CodeBuilder(self.indent_amount)
- self.code.append(sect)
- return sect
+ section = CodeBuilder(self.indent_level)
+ self.code.append(section)
+ return section
+
+ INDENT_STEP = 4 # PEP8 says so!
def indent(self):
"""Increase the current indent for following lines."""
- self.indent_amount += 4
+ self.indent_level += self.INDENT_STEP
def dedent(self):
"""Decrease the current indent for following lines."""
- self.indent_amount -= 4
+ self.indent_level -= self.INDENT_STEP
- def __str__(self):
- return "".join(str(c) for c in self.code)
-
- def get_function(self, fn_name):
- """Compile the code, and return the function `fn_name`."""
- assert self.indent_amount == 0
- g = {}
- code_text = str(self)
- exec(code_text, g)
- return g[fn_name]
+ def get_globals(self):
+ """Execute the code, and return a dict of globals it defines."""
+ # A check that the caller really finished all the blocks they started.
+ assert self.indent_level == 0
+ # Get the Python source as a single string.
+ python_source = str(self)
+ # Execute the source, defining globals, and return them.
+ global_namespace = {}
+ exec(python_source, global_namespace)
+ return global_namespace
class Templite(object):
@@ -68,7 +76,20 @@ class Templite(object):
{# This will be ignored #}
Construct a Templite with the template text, then use `render` against a
- dictionary context to create a finished string.
+ dictionary context to create a finished string::
+
+ templite = Templite('''
+ <h1>Hello {{name|upper}}!</h1>
+ {% for topic in topics %}
+ <p>You are interested in {{topic}}.</p>
+ {% endif %}
+ ''',
+ {'upper': str.upper},
+ )
+ text = templite.render({
+ 'name': "Ned",
+ 'topics': ['Python', 'Geometry', 'Juggling'],
+ })
"""
def __init__(self, text, *contexts):
@@ -78,110 +99,135 @@ class Templite(object):
These are good for filters and global values.
"""
- self.text = text
self.context = {}
for context in contexts:
self.context.update(context)
+ self.all_vars = set()
+ self.loop_vars = set()
+
# We construct a function in source form, then compile it and hold onto
# it, and execute it to render the template.
code = CodeBuilder()
- code.add_line("def render(ctx, dot):")
+ code.add_line("def render_function(context, do_dots):")
code.indent()
vars_code = code.add_section()
- self.all_vars = set()
- self.loop_vars = set()
code.add_line("result = []")
- code.add_line("a = result.append")
- code.add_line("e = result.extend")
- code.add_line("s = str")
+ code.add_line("append_result = result.append")
+ code.add_line("extend_result = result.extend")
+ code.add_line("to_str = str")
buffered = []
def flush_output():
"""Force `buffered` to the code builder."""
if len(buffered) == 1:
- code.add_line("a(%s)" % buffered[0])
+ code.add_line("append_result(%s)" % buffered[0])
elif len(buffered) > 1:
- code.add_line("e([%s])" % ",".join(buffered))
+ code.add_line("extend_result([%s])" % ", ".join(buffered))
del buffered[:]
+ ops_stack = []
+
# Split the text to form a list of tokens.
- toks = re.split(r"(?s)({{.*?}}|{%.*?%}|{#.*?#})", text)
+ tokens = re.split(r"(?s)({{.*?}}|{%.*?%}|{#.*?#})", text)
- ops_stack = []
- for tok in toks:
- if tok.startswith('{{'):
- # An expression to evaluate.
- buffered.append("s(%s)" % self.expr_code(tok[2:-2].strip()))
- elif tok.startswith('{#'):
+ for token in tokens:
+ if token.startswith('{#'):
# Comment: ignore it and move on.
continue
- elif tok.startswith('{%'):
+ elif token.startswith('{{'):
+ # An expression to evaluate.
+ expr = self._expr_code(token[2:-2].strip())
+ buffered.append("to_str(%s)" % expr)
+ elif token.startswith('{%'):
# Action tag: split into words and parse further.
flush_output()
- words = tok[2:-2].strip().split()
+ words = token[2:-2].strip().split()
if words[0] == 'if':
# An if statement: evaluate the expression to determine if.
- assert len(words) == 2
+ if len(words) != 2:
+ self._syntax_error("Don't understand if", token)
ops_stack.append('if')
- code.add_line("if %s:" % self.expr_code(words[1]))
+ code.add_line("if %s:" % self._expr_code(words[1]))
code.indent()
elif words[0] == 'for':
# A loop: iterate over expression result.
- assert len(words) == 4 and words[2] == 'in'
+ if len(words) != 4 or words[2] != 'in':
+ self._syntax_error("Don't understand for", token)
ops_stack.append('for')
- self.loop_vars.add(words[1])
+ self._variable(words[1], self.loop_vars)
code.add_line(
"for c_%s in %s:" % (
words[1],
- self.expr_code(words[3])
+ self._expr_code(words[3])
)
)
code.indent()
elif words[0].startswith('end'):
- # Endsomething. Pop the ops stack
+ # Endsomething. Pop the ops stack.
+ if len(words) != 1:
+ self._syntax_error("Don't understand end", token)
end_what = words[0][3:]
- if ops_stack[-1] != end_what:
- raise SyntaxError("Mismatched end tag: %r" % end_what)
- ops_stack.pop()
+ if not ops_stack:
+ self._syntax_error("Too many ends", token)
+ start_what = ops_stack.pop()
+ if start_what != end_what:
+ self._syntax_error("Mismatched end tag", end_what)
code.dedent()
else:
- raise SyntaxError("Don't understand tag: %r" % words[0])
+ self._syntax_error("Don't understand tag", words[0])
else:
# Literal content. If it isn't empty, output it.
- if tok:
- buffered.append("%r" % tok)
+ if token:
+ buffered.append(repr(token))
+
+ if ops_stack:
+ self._syntax_error("Unmatched action tag", ops_stack[-1])
+
flush_output()
for var_name in self.all_vars - self.loop_vars:
- vars_code.add_line("c_%s = ctx[%r]" % (var_name, var_name))
-
- if ops_stack:
- raise SyntaxError("Unmatched action tag: %r" % ops_stack[-1])
+ vars_code.add_line("c_%s = context[%r]" % (var_name, var_name))
code.add_line("return ''.join(result)")
code.dedent()
- self.render_function = code.get_function('render')
+ self._render_function = code.get_globals()['render_function']
- def expr_code(self, expr):
+ def _expr_code(self, expr):
"""Generate a Python expression for `expr`."""
if "|" in expr:
pipes = expr.split("|")
- code = self.expr_code(pipes[0])
+ code = self._expr_code(pipes[0])
for func in pipes[1:]:
- self.all_vars.add(func)
+ self._variable(func, self.all_vars)
code = "c_%s(%s)" % (func, code)
elif "." in expr:
dots = expr.split(".")
- code = self.expr_code(dots[0])
+ code = self._expr_code(dots[0])
args = ", ".join(repr(d) for d in dots[1:])
- code = "dot(%s, %s)" % (code, args)
+ code = "do_dots(%s, %s)" % (code, args)
else:
- self.all_vars.add(expr)
+ self._variable(expr, self.all_vars)
code = "c_%s" % expr
return code
+ def _syntax_error(self, msg, thing):
+ """Raise a syntax error using `msg`, and showing `thing`."""
+ raise TempliteSyntaxError("%s: %r" % (msg, thing))
+
+ def _variable(self, name, vars_set):
+ """Track that `name` is used as a variable.
+
+ Adds the name to `vars_set`, a set of variable names.
+
+ Raises an syntax error if `name` is not a valid name.
+
+ """
+ if not re.match(r"[_a-zA-Z][_a-zA-Z0-9]*$", name):
+ self._syntax_error("Not a valid name", name)
+ vars_set.add(name)
+
def render(self, context=None):
"""Render this template by applying it to `context`.
@@ -189,18 +235,18 @@ class Templite(object):
"""
# Make the complete context we'll use.
- ctx = dict(self.context)
+ render_context = dict(self.context)
if context:
- ctx.update(context)
- return self.render_function(ctx, self.do_dots)
+ render_context.update(context)
+ return self._render_function(render_context, self._do_dots)
- def do_dots(self, value, *dots):
+ def _do_dots(self, value, *dots):
"""Evaluate dotted expressions at runtime."""
for dot in dots:
try:
value = getattr(value, dot)
except AttributeError:
value = value[dot]
- if hasattr(value, '__call__'):
+ if callable(value):
value = value()
return value
diff --git a/coverage/test_helpers.py b/coverage/test_helpers.py
new file mode 100644
index 00000000..efe68dcd
--- /dev/null
+++ b/coverage/test_helpers.py
@@ -0,0 +1,258 @@
+"""Mixin classes to help make good tests."""
+
+import atexit
+import collections
+import os
+import random
+import shutil
+import sys
+import tempfile
+import textwrap
+
+from coverage.backunittest import TestCase
+from coverage.backward import StringIO, to_bytes
+
+
+class Tee(object):
+ """A file-like that writes to all the file-likes it has."""
+
+ def __init__(self, *files):
+ """Make a Tee that writes to all the files in `files.`"""
+ self._files = files
+ if hasattr(files[0], "encoding"):
+ self.encoding = files[0].encoding
+
+ def write(self, data):
+ """Write `data` to all the files."""
+ for f in self._files:
+ f.write(data)
+
+ if 0:
+ # Use this if you need to use a debugger, though it makes some tests
+ # fail, I'm not sure why...
+ def __getattr__(self, name):
+ return getattr(self._files[0], name)
+
+
+class ModuleAwareMixin(TestCase):
+ """A test case mixin that isolates changes to sys.modules."""
+
+ def setUp(self):
+ super(ModuleAwareMixin, self).setUp()
+
+ # Record sys.modules here so we can restore it in tearDown.
+ self.old_modules = dict(sys.modules)
+ self.addCleanup(self.cleanup_modules)
+
+ def cleanup_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]:
+ del sys.modules[m]
+
+
+class SysPathAwareMixin(TestCase):
+ """A test case mixin that isolates changes to sys.path."""
+
+ def setUp(self):
+ super(SysPathAwareMixin, self).setUp()
+
+ self.old_syspath = sys.path[:]
+ self.addCleanup(self.cleanup_syspath)
+
+ def cleanup_syspath(self):
+ """Restore the original sys.path."""
+ sys.path = self.old_syspath
+
+
+class EnvironmentAwareMixin(TestCase):
+ """A test case mixin that isolates changes to the environment."""
+
+ def setUp(self):
+ super(EnvironmentAwareMixin, self).setUp()
+
+ # Record environment variables that we changed with set_environ.
+ self.environ_undos = {}
+
+ self.addCleanup(self.cleanup_environ)
+
+ def set_environ(self, name, value):
+ """Set an environment variable `name` to be `value`.
+
+ The environment variable is set, and record is kept that it was set,
+ so that `tearDown` can restore its original value.
+
+ """
+ if name not in self.environ_undos:
+ self.environ_undos[name] = os.environ.get(name)
+ os.environ[name] = value
+
+ def cleanup_environ(self):
+ """Undo all the changes made by `set_environ`."""
+ for name, value in self.environ_undos.items():
+ if value is None:
+ del os.environ[name]
+ else:
+ os.environ[name] = value
+
+
+class StdStreamCapturingMixin(TestCase):
+ """A test case mixin that captures stdout and stderr."""
+
+ def setUp(self):
+ super(StdStreamCapturingMixin, self).setUp()
+
+ # Capture stdout and stderr so we can examine them in tests.
+ # nose keeps stdout from littering the screen, so we can safely Tee it,
+ # but it doesn't capture stderr, so we don't want to Tee stderr to the
+ # real stderr, since it will interfere with our nice field of dots.
+ self.old_stdout = sys.stdout
+ self.captured_stdout = StringIO()
+ sys.stdout = Tee(sys.stdout, self.captured_stdout)
+ self.old_stderr = sys.stderr
+ self.captured_stderr = StringIO()
+ sys.stderr = self.captured_stderr
+
+ self.addCleanup(self.cleanup_std_streams)
+
+ def cleanup_std_streams(self):
+ """Restore stdout and stderr."""
+ sys.stdout = self.old_stdout
+ sys.stderr = self.old_stderr
+
+ def stdout(self):
+ """Return the data written to stdout during the test."""
+ return self.captured_stdout.getvalue()
+
+ def stderr(self):
+ """Return the data written to stderr during the test."""
+ return self.captured_stderr.getvalue()
+
+
+class TempDirMixin(TestCase):
+ """A test case mixin that creates a temp directory and files in it."""
+
+ # Our own setting: most of these tests run in their own temp directory.
+ run_in_temp_dir = True
+
+ def setUp(self):
+ super(TempDirMixin, self).setUp()
+
+ if self.run_in_temp_dir:
+ # Create a temporary directory.
+ noise = str(random.random())[2:]
+ self.temp_root = os.path.join(tempfile.gettempdir(), 'test_cover')
+ self.temp_dir = os.path.join(self.temp_root, noise)
+ os.makedirs(self.temp_dir)
+ self.old_dir = os.getcwd()
+ os.chdir(self.temp_dir)
+
+ # Modules should be importable from this temp directory. We don't
+ # use '' because we make lots of different temp directories and
+ # nose's caching importer can get confused. The full path prevents
+ # problems.
+ sys.path.insert(0, os.getcwd())
+
+ class_behavior = self.class_behavior()
+ class_behavior.tests += 1
+ class_behavior.test_method_made_any_files = False
+ class_behavior.temp_dir = self.run_in_temp_dir
+
+ self.addCleanup(self.cleanup_temp_dir)
+
+ def cleanup_temp_dir(self):
+ """Clean up the temp directories we made."""
+
+ if self.run_in_temp_dir:
+ # Get rid of the temporary directory.
+ os.chdir(self.old_dir)
+ shutil.rmtree(self.temp_root)
+
+ class_behavior = self.class_behavior()
+ if class_behavior.test_method_made_any_files:
+ class_behavior.tests_making_files += 1
+
+ def make_file(self, filename, text="", newline=None):
+ """Create a file for testing.
+
+ `filename` is the relative path to the file, including directories if
+ desired, which will be created if need be. `text` is the content to
+ create in the file. If `newline` is provided, it is a string that will
+ be used as the line endings in the created file, otherwise the line
+ endings are as provided in `text`.
+
+ Returns `filename`.
+
+ """
+ # Tests that call `make_file` should be run in a temp environment.
+ assert self.run_in_temp_dir
+ self.class_behavior().test_method_made_any_files = True
+
+ text = textwrap.dedent(text)
+ if newline:
+ text = text.replace("\n", newline)
+
+ # Make sure the directories are available.
+ dirs, _ = os.path.split(filename)
+ if dirs and not os.path.exists(dirs):
+ os.makedirs(dirs)
+
+ # Create the file.
+ with open(filename, 'wb') as f:
+ f.write(to_bytes(text))
+
+ return filename
+
+ # We run some tests in temporary directories, because they may need to make
+ # files for the tests. But this is expensive, so we can change per-class
+ # whether a temp dir is used or not. It's easy to forget to set that
+ # option properly, so we track information about what the tests did, and
+ # then report at the end of the process on test classes that were set
+ # wrong.
+
+ class ClassBehavior(object):
+ """A value object to store per-class."""
+ def __init__(self):
+ self.tests = 0
+ self.temp_dir = True
+ self.tests_making_files = 0
+ self.test_method_made_any_files = False
+
+ # Map from class to info about how it ran.
+ class_behaviors = collections.defaultdict(ClassBehavior)
+
+ @classmethod
+ def report_on_class_behavior(cls):
+ """Called at process exit to report on class behavior."""
+ for test_class, behavior in cls.class_behaviors.items():
+ if behavior.temp_dir and behavior.tests_making_files == 0:
+ bad = "Inefficient"
+ elif not behavior.temp_dir and behavior.tests_making_files > 0:
+ bad = "Unsafe"
+ else:
+ bad = ""
+
+ if bad:
+ if behavior.temp_dir:
+ where = "in a temp directory"
+ else:
+ where = "without a temp directory"
+ print(
+ "%s: %s ran %d tests, %d made files %s" % (
+ bad,
+ test_class.__name__,
+ behavior.tests,
+ behavior.tests_making_files,
+ where,
+ )
+ )
+
+ def class_behavior(self):
+ """Get the ClassBehavior instance for this test."""
+ return self.class_behaviors[self.__class__]
+
+# When the process ends, find out about bad classes.
+atexit.register(TempDirMixin.report_on_class_behavior)
diff --git a/coverage/tracer.c b/coverage/tracer.c
index 97dd113b..5bf5c462 100644
--- a/coverage/tracer.c
+++ b/coverage/tracer.c
@@ -30,6 +30,7 @@
#define MyText_AS_BYTES(o) PyUnicode_AsASCIIString(o)
#define MyText_AS_STRING(o) PyBytes_AS_STRING(o)
#define MyInt_FromLong(l) PyLong_FromLong(l)
+#define MyInt_AsLong(o) PyLong_AsLong(o)
#define MyType_HEAD_INIT PyVarObject_HEAD_INIT(NULL, 0)
@@ -40,6 +41,7 @@
#define MyText_AS_BYTES(o) (Py_INCREF(o), o)
#define MyText_AS_STRING(o) PyString_AS_STRING(o)
#define MyInt_FromLong(l) PyInt_FromLong(l)
+#define MyInt_AsLong(o) PyInt_AsLong(o)
#define MyType_HEAD_INIT PyObject_HEAD_INIT(NULL) 0,
@@ -54,10 +56,23 @@
frame.
*/
typedef struct {
- PyObject * file_data; /* PyMem_Malloc'ed, a borrowed ref. */
+ /* The current file_data dictionary. Borrowed. */
+ PyObject * file_data;
+
+ /* The line number of the last line recorded, for tracing arcs.
+ -1 means there was no previous line, as when entering a code object.
+ */
int last_line;
} DataStackEntry;
+/* A data stack is a dynamically allocated vector of DataStackEntry's. */
+typedef struct {
+ int depth; /* The index of the last-used entry in stack. */
+ int alloc; /* number of entries allocated at stack. */
+ /* The file data at each level, or NULL if not recording. */
+ DataStackEntry * stack;
+} DataStack;
+
/* The CTracer type. */
typedef struct {
@@ -66,7 +81,9 @@ typedef struct {
/* Python objects manipulated directly by the Collector class. */
PyObject * should_trace;
PyObject * warn;
+ PyObject * coroutine_id_func;
PyObject * data;
+ PyObject * plugin_data;
PyObject * should_trace_cache;
PyObject * arcs;
@@ -86,19 +103,17 @@ typedef struct {
the keys are line numbers. In both cases, the value is irrelevant
(None).
*/
- /* The index of the last-used entry in data_stack. */
- int depth;
- /* The file data at each level, or NULL if not recording. */
- DataStackEntry * data_stack;
- int data_stack_alloc; /* number of entries allocated at data_stack. */
- /* The current file_data dictionary. Borrowed. */
- PyObject * cur_file_data;
+ DataStack data_stack; /* Used if we aren't doing coroutines. */
+ PyObject * data_stack_index; /* Used if we are doing coroutines. */
+ DataStack * data_stacks;
+ int data_stacks_alloc;
+ int data_stacks_used;
- /* The line number of the last line recorded, for tracing arcs.
- -1 means there was no previous line, as when entering a code object.
- */
- int last_line;
+ DataStack * pdata_stack;
+
+ /* The current file's data stack entry, copied from the stack. */
+ DataStackEntry cur_entry;
/* The parent frame for the last exception event, to fix missing returns. */
PyFrameObject * last_exc_back;
@@ -119,9 +134,47 @@ typedef struct {
#endif /* COLLECT_STATS */
} CTracer;
+
#define STACK_DELTA 100
static int
+DataStack_init(CTracer *self, DataStack *pdata_stack)
+{
+ pdata_stack->depth = -1;
+ pdata_stack->stack = NULL;
+ pdata_stack->alloc = 0;
+ return RET_OK;
+}
+
+static void
+DataStack_dealloc(CTracer *self, DataStack *pdata_stack)
+{
+ PyMem_Free(pdata_stack->stack);
+}
+
+static int
+DataStack_grow(CTracer *self, DataStack *pdata_stack)
+{
+ pdata_stack->depth++;
+ if (pdata_stack->depth >= pdata_stack->alloc) {
+ STATS( self->stats.stack_reallocs++; )
+ /* We've outgrown our data_stack array: make it bigger. */
+ int bigger = pdata_stack->alloc + STACK_DELTA;
+ DataStackEntry * bigger_data_stack = PyMem_Realloc(pdata_stack->stack, bigger * sizeof(DataStackEntry));
+ if (bigger_data_stack == NULL) {
+ STATS( self->stats.errors++; )
+ PyErr_NoMemory();
+ pdata_stack->depth--;
+ return RET_ERROR;
+ }
+ pdata_stack->stack = bigger_data_stack;
+ pdata_stack->alloc = bigger;
+ }
+ return RET_OK;
+}
+
+
+static int
CTracer_init(CTracer *self, PyObject *args_unused, PyObject *kwds_unused)
{
#if COLLECT_STATS
@@ -138,24 +191,32 @@ CTracer_init(CTracer *self, PyObject *args_unused, PyObject *kwds_unused)
self->should_trace = NULL;
self->warn = NULL;
+ self->coroutine_id_func = NULL;
self->data = NULL;
+ self->plugin_data = NULL;
self->should_trace_cache = NULL;
self->arcs = NULL;
self->started = 0;
self->tracing_arcs = 0;
- self->depth = -1;
- self->data_stack = PyMem_Malloc(STACK_DELTA*sizeof(DataStackEntry));
- if (self->data_stack == NULL) {
+ if (DataStack_init(self, &self->data_stack)) {
+ return RET_ERROR;
+ }
+ self->data_stack_index = PyDict_New();
+ if (self->data_stack_index == NULL) {
STATS( self->stats.errors++; )
- PyErr_NoMemory();
return RET_ERROR;
}
- self->data_stack_alloc = STACK_DELTA;
- self->cur_file_data = NULL;
- self->last_line = -1;
+ self->data_stacks = NULL;
+ self->data_stacks_alloc = 0;
+ self->data_stacks_used = 0;
+
+ self->pdata_stack = &self->data_stack;
+
+ self->cur_entry.file_data = NULL;
+ self->cur_entry.last_line = -1;
self->last_exc_back = NULL;
@@ -165,16 +226,28 @@ CTracer_init(CTracer *self, PyObject *args_unused, PyObject *kwds_unused)
static void
CTracer_dealloc(CTracer *self)
{
+ int i;
+
if (self->started) {
PyEval_SetTrace(NULL, NULL);
}
Py_XDECREF(self->should_trace);
Py_XDECREF(self->warn);
+ Py_XDECREF(self->coroutine_id_func);
Py_XDECREF(self->data);
+ Py_XDECREF(self->plugin_data);
Py_XDECREF(self->should_trace_cache);
- PyMem_Free(self->data_stack);
+ DataStack_dealloc(self, &self->data_stack);
+ if (self->data_stacks) {
+ for (i = 0; i < self->data_stacks_used; i++) {
+ DataStack_dealloc(self, self->data_stacks + i);
+ }
+ PyMem_Free(self->data_stacks);
+ }
+
+ Py_XDECREF(self->data_stack_index);
Py_TYPE(self)->tp_free((PyObject*)self);
}
@@ -229,7 +302,7 @@ showlog(int depth, int lineno, PyObject * filename, const char * msg)
static const char * what_sym[] = {"CALL", "EXC ", "LINE", "RET "};
#endif
-/* Record a pair of integers in self->cur_file_data. */
+/* Record a pair of integers in self->cur_entry.file_data. */
static int
CTracer_record_pair(CTracer *self, int l1, int l2)
{
@@ -237,7 +310,7 @@ CTracer_record_pair(CTracer *self, int l1, int l2)
PyObject * t = Py_BuildValue("(ii)", l1, l2);
if (t != NULL) {
- if (PyDict_SetItem(self->cur_file_data, t, Py_None) < 0) {
+ if (PyDict_SetItem(self->cur_entry.file_data, t, Py_None) < 0) {
STATS( self->stats.errors++; )
ret = RET_ERROR;
}
@@ -250,6 +323,63 @@ CTracer_record_pair(CTracer *self, int l1, int l2)
return ret;
}
+/* Set self->pdata_stack to the proper data_stack to use. */
+static int
+CTracer_set_pdata_stack(CTracer *self)
+{
+ if (self->coroutine_id_func != Py_None) {
+ PyObject * co_obj = NULL;
+ PyObject * stack_index = NULL;
+ long the_index = 0;
+
+ co_obj = PyObject_CallObject(self->coroutine_id_func, NULL);
+ if (co_obj == NULL) {
+ return RET_ERROR;
+ }
+ stack_index = PyDict_GetItem(self->data_stack_index, co_obj);
+ if (stack_index == NULL) {
+ /* A new coroutine object. Make a new data stack. */
+ the_index = self->data_stacks_used;
+ stack_index = MyInt_FromLong(the_index);
+ if (PyDict_SetItem(self->data_stack_index, co_obj, stack_index) < 0) {
+ STATS( self->stats.errors++; )
+ Py_XDECREF(co_obj);
+ Py_XDECREF(stack_index);
+ return RET_ERROR;
+ }
+ self->data_stacks_used++;
+ if (self->data_stacks_used >= self->data_stacks_alloc) {
+ int bigger = self->data_stacks_alloc + 10;
+ DataStack * bigger_stacks = PyMem_Realloc(self->data_stacks, bigger * sizeof(DataStack));
+ if (bigger_stacks == NULL) {
+ STATS( self->stats.errors++; )
+ PyErr_NoMemory();
+ Py_XDECREF(co_obj);
+ Py_XDECREF(stack_index);
+ return RET_ERROR;
+ }
+ self->data_stacks = bigger_stacks;
+ self->data_stacks_alloc = bigger;
+ }
+ DataStack_init(self, &self->data_stacks[the_index]);
+ }
+ else {
+ Py_INCREF(stack_index);
+ the_index = MyInt_AsLong(stack_index);
+ }
+
+ self->pdata_stack = &self->data_stacks[the_index];
+
+ Py_XDECREF(co_obj);
+ Py_XDECREF(stack_index);
+ }
+ else {
+ self->pdata_stack = &self->data_stack;
+ }
+
+ return RET_OK;
+}
+
/*
* The Trace Function
*/
@@ -259,6 +389,8 @@ CTracer_trace(CTracer *self, PyFrameObject *frame, int what, PyObject *arg_unuse
int ret = RET_OK;
PyObject * filename = NULL;
PyObject * tracename = NULL;
+ PyObject * disposition = NULL;
+ PyObject * disp_trace = NULL;
#if WHAT_LOG || TRACE_LOG
PyObject * ascii = NULL;
#endif
@@ -293,16 +425,18 @@ CTracer_trace(CTracer *self, PyFrameObject *frame, int what, PyObject *arg_unuse
we'll need to keep more of the missed frame's state.
*/
STATS( self->stats.missed_returns++; )
- if (self->depth >= 0) {
- if (self->tracing_arcs && self->cur_file_data) {
- if (CTracer_record_pair(self, self->last_line, -self->last_exc_firstlineno) < 0) {
+ if (CTracer_set_pdata_stack(self)) {
+ return RET_ERROR;
+ }
+ if (self->pdata_stack->depth >= 0) {
+ if (self->tracing_arcs && self->cur_entry.file_data) {
+ if (CTracer_record_pair(self, self->cur_entry.last_line, -self->last_exc_firstlineno) < 0) {
return RET_ERROR;
}
}
- SHOWLOG(self->depth, frame->f_lineno, frame->f_code->co_filename, "missedreturn");
- self->cur_file_data = self->data_stack[self->depth].file_data;
- self->last_line = self->data_stack[self->depth].last_line;
- self->depth--;
+ SHOWLOG(self->pdata_stack->depth, frame->f_lineno, frame->f_code->co_filename, "missedreturn");
+ self->cur_entry = self->pdata_stack->stack[self->pdata_stack->depth];
+ self->pdata_stack->depth--;
}
}
self->last_exc_back = NULL;
@@ -313,109 +447,158 @@ CTracer_trace(CTracer *self, PyFrameObject *frame, int what, PyObject *arg_unuse
case PyTrace_CALL: /* 0 */
STATS( self->stats.calls++; )
/* Grow the stack. */
- self->depth++;
- if (self->depth >= self->data_stack_alloc) {
- STATS( self->stats.stack_reallocs++; )
- /* We've outgrown our data_stack array: make it bigger. */
- int bigger = self->data_stack_alloc + STACK_DELTA;
- DataStackEntry * bigger_data_stack = PyMem_Realloc(self->data_stack, bigger * sizeof(DataStackEntry));
- if (bigger_data_stack == NULL) {
- STATS( self->stats.errors++; )
- PyErr_NoMemory();
- self->depth--;
- return RET_ERROR;
- }
- self->data_stack = bigger_data_stack;
- self->data_stack_alloc = bigger;
+ if (CTracer_set_pdata_stack(self)) {
+ return RET_ERROR;
+ }
+ if (DataStack_grow(self, self->pdata_stack)) {
+ return RET_ERROR;
}
/* Push the current state on the stack. */
- self->data_stack[self->depth].file_data = self->cur_file_data;
- self->data_stack[self->depth].last_line = self->last_line;
+ self->pdata_stack->stack[self->pdata_stack->depth] = self->cur_entry;
/* Check if we should trace this line. */
filename = frame->f_code->co_filename;
- tracename = PyDict_GetItem(self->should_trace_cache, filename);
- if (tracename == NULL) {
+ disposition = PyDict_GetItem(self->should_trace_cache, filename);
+ if (disposition == NULL) {
STATS( self->stats.new_files++; )
/* We've never considered this file before. */
/* Ask should_trace about it. */
PyObject * args = Py_BuildValue("(OO)", filename, frame);
- tracename = PyObject_Call(self->should_trace, args, NULL);
+ disposition = PyObject_Call(self->should_trace, args, NULL);
Py_DECREF(args);
- if (tracename == NULL) {
+ if (disposition == NULL) {
/* An error occurred inside should_trace. */
STATS( self->stats.errors++; )
return RET_ERROR;
}
- if (PyDict_SetItem(self->should_trace_cache, filename, tracename) < 0) {
+ if (PyDict_SetItem(self->should_trace_cache, filename, disposition) < 0) {
STATS( self->stats.errors++; )
return RET_ERROR;
}
}
else {
- Py_INCREF(tracename);
+ Py_INCREF(disposition);
+ }
+
+ disp_trace = PyObject_GetAttrString(disposition, "trace");
+ if (disp_trace == NULL) {
+ STATS( self->stats.errors++; )
+ Py_DECREF(disposition);
+ return RET_ERROR;
+ }
+
+ tracename = Py_None;
+ Py_INCREF(tracename);
+
+ if (disp_trace == Py_True) {
+ /* If tracename is a string, then we're supposed to trace. */
+ tracename = PyObject_GetAttrString(disposition, "source_filename");
+ if (tracename == NULL) {
+ STATS( self->stats.errors++; )
+ Py_DECREF(disposition);
+ Py_DECREF(disp_trace);
+ return RET_ERROR;
+ }
}
+ Py_DECREF(disp_trace);
- /* If tracename is a string, then we're supposed to trace. */
if (MyText_Check(tracename)) {
PyObject * file_data = PyDict_GetItem(self->data, tracename);
+ PyObject * disp_plugin = NULL;
+ PyObject * disp_plugin_name = NULL;
+
if (file_data == NULL) {
file_data = PyDict_New();
if (file_data == NULL) {
STATS( self->stats.errors++; )
+ Py_DECREF(tracename);
+ Py_DECREF(disposition);
return RET_ERROR;
}
ret = PyDict_SetItem(self->data, tracename, file_data);
Py_DECREF(file_data);
if (ret < 0) {
STATS( self->stats.errors++; )
+ Py_DECREF(tracename);
+ Py_DECREF(disposition);
return RET_ERROR;
}
+
+ if (self->plugin_data != NULL) {
+ /* If the disposition mentions a plugin, record that. */
+ disp_plugin = PyObject_GetAttrString(disposition, "plugin");
+ if (disp_plugin == NULL) {
+ STATS( self->stats.errors++; )
+ Py_DECREF(tracename);
+ Py_DECREF(disposition);
+ return RET_ERROR;
+ }
+ if (disp_plugin != Py_None) {
+ disp_plugin_name = PyObject_GetAttrString(disp_plugin, "__name__");
+ Py_DECREF(disp_plugin);
+ if (disp_plugin_name == NULL) {
+ STATS( self->stats.errors++; )
+ Py_DECREF(tracename);
+ Py_DECREF(disposition);
+ return RET_ERROR;
+ }
+ ret = PyDict_SetItem(self->plugin_data, tracename, disp_plugin_name);
+ Py_DECREF(disp_plugin_name);
+ if (ret < 0) {
+ Py_DECREF(tracename);
+ Py_DECREF(disposition);
+ return RET_ERROR;
+ }
+ }
+ }
}
- self->cur_file_data = file_data;
+ self->cur_entry.file_data = file_data;
/* Make the frame right in case settrace(gettrace()) happens. */
Py_INCREF(self);
frame->f_trace = (PyObject*)self;
- SHOWLOG(self->depth, frame->f_lineno, filename, "traced");
+ SHOWLOG(self->pdata_stack->depth, frame->f_lineno, filename, "traced");
}
else {
- self->cur_file_data = NULL;
- SHOWLOG(self->depth, frame->f_lineno, filename, "skipped");
+ self->cur_entry.file_data = NULL;
+ SHOWLOG(self->pdata_stack->depth, frame->f_lineno, filename, "skipped");
}
Py_DECREF(tracename);
+ Py_DECREF(disposition);
- self->last_line = -1;
+ self->cur_entry.last_line = -1;
break;
case PyTrace_RETURN: /* 3 */
STATS( self->stats.returns++; )
/* A near-copy of this code is above in the missing-return handler. */
- if (self->depth >= 0) {
- if (self->tracing_arcs && self->cur_file_data) {
+ if (CTracer_set_pdata_stack(self)) {
+ return RET_ERROR;
+ }
+ 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->last_line, -first) < 0) {
+ if (CTracer_record_pair(self, self->cur_entry.last_line, -first) < 0) {
return RET_ERROR;
}
}
- SHOWLOG(self->depth, frame->f_lineno, frame->f_code->co_filename, "return");
- self->cur_file_data = self->data_stack[self->depth].file_data;
- self->last_line = self->data_stack[self->depth].last_line;
- self->depth--;
+ SHOWLOG(self->pdata_stack->depth, frame->f_lineno, frame->f_code->co_filename, "return");
+ self->cur_entry = self->pdata_stack->stack[self->pdata_stack->depth];
+ self->pdata_stack->depth--;
}
break;
case PyTrace_LINE: /* 2 */
STATS( self->stats.lines++; )
- if (self->depth >= 0) {
- SHOWLOG(self->depth, frame->f_lineno, frame->f_code->co_filename, "line");
- if (self->cur_file_data) {
+ if (self->pdata_stack->depth >= 0) {
+ SHOWLOG(self->pdata_stack->depth, frame->f_lineno, frame->f_code->co_filename, "line");
+ if (self->cur_entry.file_data) {
/* We're tracing in this frame: record something. */
if (self->tracing_arcs) {
/* Tracing arcs: key is (last_line,this_line). */
- if (CTracer_record_pair(self, self->last_line, frame->f_lineno) < 0) {
+ if (CTracer_record_pair(self, self->cur_entry.last_line, frame->f_lineno) < 0) {
return RET_ERROR;
}
}
@@ -426,7 +609,7 @@ CTracer_trace(CTracer *self, PyFrameObject *frame, int what, PyObject *arg_unuse
STATS( self->stats.errors++; )
return RET_ERROR;
}
- ret = PyDict_SetItem(self->cur_file_data, this_line, Py_None);
+ ret = PyDict_SetItem(self->cur_entry.file_data, this_line, Py_None);
Py_DECREF(this_line);
if (ret < 0) {
STATS( self->stats.errors++; )
@@ -434,7 +617,7 @@ CTracer_trace(CTracer *self, PyFrameObject *frame, int what, PyObject *arg_unuse
}
}
}
- self->last_line = frame->f_lineno;
+ self->cur_entry.last_line = frame->f_lineno;
}
break;
@@ -550,7 +733,7 @@ CTracer_start(CTracer *self, PyObject *args_unused)
PyEval_SetTrace((Py_tracefunc)CTracer_trace, (PyObject*)self);
self->started = 1;
self->tracing_arcs = self->arcs && PyObject_IsTrue(self->arcs);
- self->last_line = -1;
+ self->cur_entry.last_line = -1;
/* start() returns a trace function usable with sys.settrace() */
Py_INCREF(self);
@@ -582,7 +765,7 @@ CTracer_get_stats(CTracer *self)
"new_files", self->stats.new_files,
"missed_returns", self->stats.missed_returns,
"stack_reallocs", self->stats.stack_reallocs,
- "stack_alloc", self->data_stack_alloc,
+ "stack_alloc", self->pdata_stack->alloc,
"errors", self->stats.errors
);
#else
@@ -598,9 +781,15 @@ CTracer_members[] = {
{ "warn", T_OBJECT, offsetof(CTracer, warn), 0,
PyDoc_STR("Function for issuing warnings.") },
+ { "coroutine_id_func", T_OBJECT, offsetof(CTracer, coroutine_id_func), 0,
+ PyDoc_STR("Function for determining coroutine context") },
+
{ "data", T_OBJECT, offsetof(CTracer, data), 0,
PyDoc_STR("The raw dictionary of trace data.") },
+ { "plugin_data", T_OBJECT, offsetof(CTracer, plugin_data), 0,
+ PyDoc_STR("Mapping from filename to plugin name.") },
+
{ "should_trace_cache", T_OBJECT, offsetof(CTracer, should_trace_cache), 0,
PyDoc_STR("Dictionary caching should_trace results.") },
diff --git a/coverage/xmlreport.py b/coverage/xmlreport.py
index f5a4c1ba..92a8975a 100644
--- a/coverage/xmlreport.py
+++ b/coverage/xmlreport.py
@@ -17,7 +17,8 @@ class XmlReporter(Reporter):
def __init__(self, coverage, config):
super(XmlReporter, self).__init__(coverage, config)
- self.packages = None
+ self.source_paths = set()
+ self.packages = {}
self.xml_out = None
self.arcs = coverage.data.has_arcs()
@@ -47,16 +48,26 @@ class XmlReporter(Reporter):
xcoverage.appendChild(self.xml_out.createComment(
" Generated by coverage.py: %s " % __url__
))
- xpackages = self.xml_out.createElement("packages")
- xcoverage.appendChild(xpackages)
# Call xml_file for each file in the data.
- self.packages = {}
self.report_files(self.xml_file, morfs)
+ xsources = self.xml_out.createElement("sources")
+ xcoverage.appendChild(xsources)
+
+ # Populate the XML DOM with the source info.
+ for path in sorted(self.source_paths):
+ xsource = self.xml_out.createElement("source")
+ xsources.appendChild(xsource)
+ txt = self.xml_out.createTextNode(path)
+ xsource.appendChild(txt)
+
lnum_tot, lhits_tot = 0, 0
bnum_tot, bhits_tot = 0, 0
+ xpackages = self.xml_out.createElement("packages")
+ xcoverage.appendChild(xpackages)
+
# Populate the XML DOM with the package info.
for pkg_name in sorted(self.packages.keys()):
pkg_data = self.packages[pkg_name]
@@ -99,6 +110,7 @@ class XmlReporter(Reporter):
package_name = cu.name.rpartition(".")[0]
className = cu.name
+ self.source_paths.add(cu.file_locator.relative_dir.rstrip('/'))
package = self.packages.setdefault(package_name, [{}, 0, 0, 0, 0])
xclass = self.xml_out.createElement("class")