diff options
-rw-r--r-- | CHANGES.rst | 5 | ||||
-rw-r--r-- | coverage/collector.py | 30 | ||||
-rw-r--r-- | coverage/config.py | 4 | ||||
-rw-r--r-- | coverage/control.py | 14 | ||||
-rw-r--r-- | coverage/results.py | 4 | ||||
-rw-r--r-- | doc/cmd.rst | 7 | ||||
-rw-r--r-- | doc/config.rst | 9 | ||||
-rw-r--r-- | doc/whatsnew5x.rst | 5 | ||||
-rw-r--r-- | tests/test_api.py | 91 |
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 |