summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGES.rst5
-rw-r--r--coverage/collector.py30
-rw-r--r--coverage/config.py4
-rw-r--r--coverage/control.py14
-rw-r--r--coverage/results.py4
-rw-r--r--doc/cmd.rst7
-rw-r--r--doc/config.rst9
-rw-r--r--doc/whatsnew5x.rst5
-rw-r--r--tests/test_api.py91
9 files changed, 146 insertions, 23 deletions
diff --git a/CHANGES.rst b/CHANGES.rst
index 4eec16e9..59c89f6d 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -24,6 +24,11 @@ want to know what's different in 5.0 since 4.5.x, see :ref:`whatsnew5x`.
Unreleased
----------
+- An experimental ``[run] relative_files`` setting tells coverage to store
+ relative file names in the data file. This makes it easier to run tests in
+ one (or many) environments, and then report in another. It has not had much
+ real-world testing, so it may change in incompatible ways in the future.
+
- Python files run with ``-m`` now have ``__spec__`` defined properly. This
fixes `issue 745`_ (about not being able to run unittest tests that spawn
subprocesses), and `issue 838`_, which described the problem directly.
diff --git a/coverage/collector.py b/coverage/collector.py
index 703f65b8..01acb78d 100644
--- a/coverage/collector.py
+++ b/coverage/collector.py
@@ -10,7 +10,6 @@ from coverage import env
from coverage.backward import litems, range # pylint: disable=redefined-builtin
from coverage.debug import short_stack
from coverage.disposition import FileDisposition
-from coverage.files import abs_file
from coverage.misc import CoverageException, isolate_module
from coverage.pytracer import PyTracer
@@ -59,7 +58,7 @@ class Collector(object):
SUPPORTED_CONCURRENCIES = set(["greenlet", "eventlet", "gevent", "thread"])
def __init__(
- self, should_trace, check_include, should_start_context,
+ self, should_trace, check_include, should_start_context, file_mapper,
timid, branch, warn, concurrency,
):
"""Create a collector.
@@ -75,6 +74,10 @@ class Collector(object):
is the new context. If the frame should not be the start of a new
context, return None.
+ `file_mapper` is a function taking a filename, and returning a Unicode
+ filename. The result is the name that will be recorded in the data
+ file.
+
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
@@ -97,6 +100,7 @@ class Collector(object):
self.should_trace = should_trace
self.check_include = check_include
self.should_start_context = should_start_context
+ self.file_mapper = file_mapper
self.warn = warn
self.branch = branch
self.threading = None
@@ -107,7 +111,7 @@ class Collector(object):
self.origin = short_stack()
self.concur_id_func = None
- self.abs_file_cache = {}
+ self.mapped_file_cache = {}
# We can handle a few concurrency options here, but only one at a time.
these_concurrencies = self.SUPPORTED_CONCURRENCIES.intersection(concurrency)
@@ -381,16 +385,16 @@ class Collector(object):
context = new_context
self.covdata.set_context(context)
- def cached_abs_file(self, filename):
- """A locally cached version of `abs_file`."""
+ def cached_mapped_file(self, filename):
+ """A locally cached version of file names mapped through file_mapper."""
key = (type(filename), filename)
try:
- return self.abs_file_cache[key]
+ return self.mapped_file_cache[key]
except KeyError:
- return self.abs_file_cache.setdefault(key, abs_file(filename))
+ return self.mapped_file_cache.setdefault(key, self.file_mapper(filename))
- def abs_file_dict(self, d):
- """Return a dict like d, but with keys modified by `abs_file`."""
+ def mapped_file_dict(self, d):
+ """Return a dict like d, but with keys modified by file_mapper."""
# The call to litems() ensures that the GIL protects the dictionary
# iterator against concurrent modifications by tracers running
# in other threads. We try three times in case of concurrent
@@ -406,7 +410,7 @@ class Collector(object):
else:
raise runtime_err
- return dict((self.cached_abs_file(k), v) for k, v in items if v)
+ return dict((self.cached_mapped_file(k), v) for k, v in items if v)
def flush_data(self):
"""Save the collected data to our associated `CoverageData`.
@@ -420,10 +424,10 @@ class Collector(object):
return False
if self.branch:
- self.covdata.add_arcs(self.abs_file_dict(self.data))
+ self.covdata.add_arcs(self.mapped_file_dict(self.data))
else:
- self.covdata.add_lines(self.abs_file_dict(self.data))
- self.covdata.add_file_tracers(self.abs_file_dict(self.file_tracers))
+ self.covdata.add_lines(self.mapped_file_dict(self.data))
+ self.covdata.add_file_tracers(self.mapped_file_dict(self.file_tracers))
self._clear_data()
return True
diff --git a/coverage/config.py b/coverage/config.py
index b8789fbf..997fc036 100644
--- a/coverage/config.py
+++ b/coverage/config.py
@@ -190,9 +190,10 @@ class CoverageConfig(object):
self.note = None
self.parallel = False
self.plugins = []
- self.source = None
+ self.relative_files = False
self.run_include = None
self.run_omit = None
+ self.source = None
self.timid = False
# Defaults for [report]
@@ -353,6 +354,7 @@ class CoverageConfig(object):
('note', 'run:note'),
('parallel', 'run:parallel', 'boolean'),
('plugins', 'run:plugins', 'list'),
+ ('relative_files', 'run:relative_files', 'boolean'),
('run_include', 'run:include', 'list'),
('run_omit', 'run:omit', 'list'),
('source', 'run:source', 'list'),
diff --git a/coverage/control.py b/coverage/control.py
index 0f306aa2..3a19695a 100644
--- a/coverage/control.py
+++ b/coverage/control.py
@@ -20,7 +20,7 @@ from coverage.context import should_start_context_test_function, combine_context
from coverage.data import CoverageData, combine_parallel_data
from coverage.debug import DebugControl, write_formatted_info
from coverage.disposition import disposition_debug_msg
-from coverage.files import PathAliases, set_relative_directory, abs_file
+from coverage.files import PathAliases, abs_file, relative_filename, set_relative_directory
from coverage.html import HtmlReporter
from coverage.inorout import InOrOut
from coverage.jsonreport import JsonReporter
@@ -198,6 +198,7 @@ class Coverage(object):
self._data_suffix = self._run_suffix = None
self._exclude_re = None
self._debug = None
+ self._file_mapper = None
# State machine variables:
# Have we initialized everything?
@@ -238,6 +239,7 @@ class Coverage(object):
self._exclude_re = {}
set_relative_directory()
+ self._file_mapper = relative_filename if self.config.relative_files else abs_file
# Load plugins
self._plugins = Plugins.load_plugins(self.config.plugins, self.config, self._debug)
@@ -405,6 +407,7 @@ class Coverage(object):
should_trace=self._should_trace,
check_include=self._check_include_omit_etc,
should_start_context=should_start_context,
+ file_mapper=self._file_mapper,
timid=self.config.timid,
branch=self.config.branch,
warn=self._warn,
@@ -671,6 +674,7 @@ class Coverage(object):
# mark completely unexecuted files as 0% covered.
if self._data:
for file_path, plugin_name in self._inorout.find_possibly_unexecuted_files():
+ file_path = self._file_mapper(file_path)
self._data.touch_file(file_path, plugin_name)
if self.config.note:
@@ -723,7 +727,7 @@ class Coverage(object):
if not isinstance(it, FileReporter):
it = self._get_file_reporter(it)
- return Analysis(data, it)
+ return Analysis(data, it, self._file_mapper)
def _get_file_reporter(self, morf):
"""Get a FileReporter for a module or file name."""
@@ -731,13 +735,13 @@ class Coverage(object):
file_reporter = "python"
if isinstance(morf, string_class):
- abs_morf = abs_file(morf)
- plugin_name = self._data.file_tracer(abs_morf)
+ mapped_morf = self._file_mapper(morf)
+ plugin_name = self._data.file_tracer(mapped_morf)
if plugin_name:
plugin = self._plugins.get(plugin_name)
if plugin:
- file_reporter = plugin.file_reporter(abs_morf)
+ file_reporter = plugin.file_reporter(mapped_morf)
if file_reporter is None:
raise CoverageException(
"Plugin %r did not provide a file reporter for %r." % (
diff --git a/coverage/results.py b/coverage/results.py
index c88da919..ae8366bf 100644
--- a/coverage/results.py
+++ b/coverage/results.py
@@ -13,10 +13,10 @@ from coverage.misc import contract, CoverageException, nice_pair
class Analysis(object):
"""The results of analyzing a FileReporter."""
- def __init__(self, data, file_reporter):
+ def __init__(self, data, file_reporter, file_mapper):
self.data = data
self.file_reporter = file_reporter
- self.filename = self.file_reporter.filename
+ self.filename = file_mapper(self.file_reporter.filename)
self.statements = self.file_reporter.lines()
self.excluded = self.file_reporter.excluded_lines()
diff --git a/doc/cmd.rst b/doc/cmd.rst
index b945bf2c..4038fd82 100644
--- a/doc/cmd.rst
+++ b/doc/cmd.rst
@@ -270,8 +270,11 @@ different subdirectory. Coverage needs to know that different file paths are
actually the same source file for reporting purposes.
You can tell coverage.py how different source locations relate with a
-``[paths]`` section in your configuration file. See :ref:`config_paths` for
-details.
+``[paths]`` section in your configuration file (see :ref:`config_paths`).
+It might be more convenient to use the ``[run] relative_files``
+setting to store relative file paths (see :ref:`relative_files
+<config_run_relative_files>`).
+
If any of the data files can't be read, coverage.py will print a warning
indicating the file and the problem.
diff --git a/doc/config.rst b/doc/config.rst
index ed0024cb..052bb023 100644
--- a/doc/config.rst
+++ b/doc/config.rst
@@ -188,6 +188,15 @@ many processes. See :ref:`cmd_combining` for more information.
``plugins`` (multi-string): a list of plugin package names. See :ref:`plugins`
for more information.
+.. _config_run_relative_files:
+
+``relative_files`` (boolean, default False): *Experimental*: store relative
+file paths in the data file. This makes it easier to measure code in one (or
+multiple) environments, and then report in another. See :ref:`cmd_combining`
+for details.
+
+.. versionadded:: 5.0
+
.. _config_run_source:
``source`` (multi-string): a list of packages or directories, the source to
diff --git a/doc/whatsnew5x.rst b/doc/whatsnew5x.rst
index 9286d4ef..78f9832a 100644
--- a/doc/whatsnew5x.rst
+++ b/doc/whatsnew5x.rst
@@ -75,6 +75,11 @@ New Features
- You can specify the command line to run your program with the ``[run]
command_line`` configuration setting, as requested in :github:`695`.
+- An experimental ``[run] relative_files`` setting tells coverage to store
+ relative file names in the data file. This makes it easier to run tests in
+ one (or many) environments, and then report in another. It has not had much
+ real-world testing, so it may change in incompatible ways in the future.
+
- Environment variable substitution in configuration files now supports two
syntaxes for controlling the behavior of undefined variables: if ``VARNAME``
is not defined, ``${VARNAME?}`` will raise an error, and ``${VARNAME-default
diff --git a/tests/test_api.py b/tests/test_api.py
index af759a07..eb022ae3 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -4,15 +4,21 @@
"""Tests for coverage.py's API."""
import fnmatch
+import glob
import os
import os.path
+import re
+import shutil
import sys
import textwrap
+from unittest_mixins import change_dir
+
import coverage
from coverage import env
from coverage.backward import StringIO, import_local_file
from coverage.data import line_counts
+from coverage.files import abs_file
from coverage.misc import CoverageException
from tests.coveragetest import CoverageTest, CoverageTestMethodsMixin, TESTS_DIR, UsingModulesMixin
@@ -940,3 +946,88 @@ class ImmutableConfigTest(CoverageTest):
self.assertEqual(cov.get_option("report:show_missing"), False)
cov.report(show_missing=True)
self.assertEqual(cov.get_option("report:show_missing"), False)
+
+
+class RelativePathTest(CoverageTest):
+ """Tests of the relative_files setting."""
+ def test_moving_stuff(self):
+ # When using absolute file names, moving the source around results in
+ # "No source for code" errors while reporting.
+ self.make_file("foo.py", "a = 1")
+ cov = coverage.Coverage(source=["."])
+ self.start_import_stop(cov, "foo")
+ res = cov.report()
+ assert res == 100
+
+ expected = re.escape("No source for code: '{}'.".format(abs_file("foo.py")))
+ os.remove("foo.py")
+ self.make_file("new/foo.py", "a = 1")
+ shutil.move(".coverage", "new/.coverage")
+ with change_dir("new"):
+ cov = coverage.Coverage()
+ cov.load()
+ with self.assertRaisesRegex(CoverageException, expected):
+ cov.report()
+
+ def test_moving_stuff_with_relative(self):
+ # When using relative file names, moving the source around is fine.
+ self.make_file("foo.py", "a = 1")
+ self.make_file(".coveragerc", """\
+ [run]
+ relative_files = true
+ """)
+ cov = coverage.Coverage(source=["."])
+ self.start_import_stop(cov, "foo")
+ res = cov.report()
+ assert res == 100
+
+ os.remove("foo.py")
+ self.make_file("new/foo.py", "a = 1")
+ shutil.move(".coverage", "new/.coverage")
+ shutil.move(".coveragerc", "new/.coveragerc")
+ with change_dir("new"):
+ cov = coverage.Coverage()
+ cov.load()
+ res = cov.report()
+ assert res == 100
+
+ def test_combine_relative(self):
+ self.make_file("dir1/foo.py", "a = 1")
+ self.make_file("dir1/.coveragerc", """\
+ [run]
+ relative_files = true
+ """)
+ with change_dir("dir1"):
+ cov = coverage.Coverage(source=["."], data_suffix=True)
+ self.start_import_stop(cov, "foo")
+ cov.save()
+ shutil.move(glob.glob(".coverage.*")[0], "..")
+
+ self.make_file("dir2/bar.py", "a = 1")
+ self.make_file("dir2/.coveragerc", """\
+ [run]
+ relative_files = true
+ """)
+ with change_dir("dir2"):
+ cov = coverage.Coverage(source=["."], data_suffix=True)
+ self.start_import_stop(cov, "bar")
+ cov.save()
+ shutil.move(glob.glob(".coverage.*")[0], "..")
+
+ self.make_file(".coveragerc", """\
+ [run]
+ relative_files = true
+ """)
+ cov = coverage.Coverage()
+ cov.combine()
+ cov.save()
+
+ self.make_file("foo.py", "a = 1")
+ self.make_file("bar.py", "a = 1")
+
+ cov = coverage.Coverage()
+ cov.load()
+ files = cov.get_data().measured_files()
+ assert files == {'foo.py', 'bar.py'}
+ res = cov.report()
+ assert res == 100