summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorNed Batchelder <ned@nedbatchelder.com>2011-08-21 14:02:20 -0400
committerNed Batchelder <ned@nedbatchelder.com>2011-08-21 14:02:20 -0400
commit5f6930a60d495c8433b64529ac2ec321d9f1f13b (patch)
tree7818acb429a18fb812075624f952dd1a2bb6b161
parent9e122f315a3f1c27aed107a8c8035b7dc9aa29bd (diff)
downloadpython-coveragepy-5f6930a60d495c8433b64529ac2ec321d9f1f13b.tar.gz
The machinery to map paths through aliases for merging coverage data from disparate machines. Part of fixing #17.
-rw-r--r--coverage/data.py12
-rw-r--r--coverage/files.py78
-rw-r--r--test/test_data.py29
-rw-r--r--test/test_files.py60
4 files changed, 173 insertions, 6 deletions
diff --git a/coverage/data.py b/coverage/data.py
index 3263cb3..1cc4c95 100644
--- a/coverage/data.py
+++ b/coverage/data.py
@@ -1,8 +1,10 @@
"""Coverage data for Coverage."""
-import os
+import fnmatch, os, re
from coverage.backward import pickle, sorted # pylint: disable=W0622
+from coverage.files import PathAliases
+from coverage.misc import CoverageException
class CoverageData(object):
@@ -169,13 +171,17 @@ class CoverageData(object):
pass
return lines, arcs
- def combine_parallel_data(self):
+ def combine_parallel_data(self, aliases=None):
"""Combine a number of data files together.
Treat `self.filename` as a file prefix, and combine the data from all
of the data files starting with that prefix plus a dot.
+ If `aliases` is provided, it's a PathAliases object that is used to
+ re-map paths to match the local machine's.
+
"""
+ aliases = aliases or PathAliases()
data_dir, local = os.path.split(self.filename)
localdot = local + '.'
for f in os.listdir(data_dir or '.'):
@@ -183,8 +189,10 @@ class CoverageData(object):
full_path = os.path.join(data_dir, f)
new_lines, new_arcs = self._read_file(full_path)
for filename, file_data in new_lines.items():
+ filename = aliases.map(filename)
self.lines.setdefault(filename, {}).update(file_data)
for filename, file_data in new_arcs.items():
+ filename = aliases.map(filename)
self.arcs.setdefault(filename, {}).update(file_data)
if f != local:
os.remove(full_path)
diff --git a/coverage/files.py b/coverage/files.py
index a68a0a7..f1046ed 100644
--- a/coverage/files.py
+++ b/coverage/files.py
@@ -1,7 +1,8 @@
"""File wrangling."""
from coverage.backward import to_string
-import fnmatch, os, sys
+from coverage.misc import CoverageException
+import fnmatch, os, re, sys
class FileLocator(object):
"""Understand how filenames work."""
@@ -118,6 +119,81 @@ class FnmatchMatcher(object):
return False
+class PathAliases(object):
+ """A collection of aliases for paths.
+
+ When combining data files from remote machines, often the paths to source
+ code are different, for example, due to OS differences, or because of
+ serialized checkouts on continuous integration machines.
+
+ A `PathAliases` object tracks a list of pattern/result pairs, and can
+ map a path through those aliases to produce a unified path.
+
+ """
+ def __init__(self):
+ self.aliases = []
+
+ def _sep(self, s):
+ """Find the path separator used in this string, or os.sep if none."""
+ sep_match = re.search(r"[\\/]", s)
+ if sep_match:
+ sep = sep_match.group(0)
+ else:
+ sep = os.sep
+ return sep
+
+ def add(self, pattern, result):
+ """Add the `pattern`/`result` pair to the list of aliases.
+
+ `pattern` is an `fnmatch`-style pattern. `result` is a simple
+ string. When mapping paths, if a path starts with a match against
+ `pattern`, then that match is replaced with `result`. This models
+ isomorphic source trees being rooted at different places on two
+ different machines.
+
+ `pattern` can't end with a wildcard component, since that would
+ match an entire tree, and not just its root.
+
+ """
+ # The pattern can't end with a wildcard component.
+ pattern = pattern.rstrip(r"\/")
+ if pattern.endswith("*"):
+ raise CoverageException("Pattern must not end with wildcards.")
+ pattern_sep = self._sep(pattern)
+ pattern += pattern_sep
+
+ # Make a regex from the pattern. fnmatch always adds a \Z to match
+ # the whole string, which we don't want.
+ regex_pat = fnmatch.translate(pattern).replace(r'\Z', '')
+ regex = re.compile("(?i)" + regex_pat)
+
+ # Normalize the result: it must end with a path separator.
+ result_sep = self._sep(result)
+ result = result.rstrip(r"\/") + result_sep
+ self.aliases.append((regex, result, pattern_sep, result_sep))
+
+ def map(self, path):
+ """Map `path` through the aliases.
+
+ `path` is checked against all of the patterns. The first pattern to
+ match is used to replace the root of the path with the result root.
+ Only one pattern is ever used. If no patterns match, `path` is
+ returned unchanged.
+
+ The separator style in the result is made to match that of the result
+ in the alias.
+
+ """
+ for regex, result, pattern_sep, result_sep in self.aliases:
+ m = regex.match(path)
+ if m:
+ new = path.replace(m.group(0), result)
+ if pattern_sep != result_sep:
+ new = new.replace(pattern_sep, result_sep)
+ return new
+ return path
+
+
def find_python_files(dirname):
"""Yield all of the importable Python files in `dirname`, recursively."""
for dirpath, dirnames, filenames in os.walk(dirname, topdown=True):
diff --git a/test/test_data.py b/test/test_data.py
index 298078a..5d0d400 100644
--- a/test/test_data.py
+++ b/test/test_data.py
@@ -4,6 +4,7 @@ import os, sys
from coverage.backward import pickle
from coverage.data import CoverageData
+from coverage.files import PathAliases
sys.path.insert(0, os.path.split(__file__)[0]) # Force relative import for Py3k
from coveragetest import CoverageTest
@@ -23,12 +24,13 @@ ARC_DATA_3 = { 'x.py': {(1,2):None, (2,3):None}, 'y.py': {(17,23):None} }
X_PY_ARCS_3 = [(1,2), (2,3)]
Y_PY_ARCS_3 = [(17,23)]
+
class DataTest(CoverageTest):
"""Test cases for coverage.data."""
- def assert_summary(self, covdata, summary):
+ def assert_summary(self, covdata, summary, fullpath=False):
"""Check that the summary of `covdata` is `summary`."""
- self.assertEqual(covdata.summary(), summary)
+ self.assertEqual(covdata.summary(fullpath), summary)
def assert_measured_files(self, covdata, measured):
"""Check that `covdata`'s measured files are `measured`."""
@@ -120,3 +122,26 @@ class DataTest(CoverageTest):
arcs = data['arcs']
self.assertSameElements(arcs['x.py'], X_PY_ARCS_3)
self.assertSameElements(arcs['y.py'], Y_PY_ARCS_3)
+
+ def test_combining_with_aliases(self):
+ covdata1 = CoverageData()
+ covdata1.add_line_data({
+ '/home/ned/proj/src/a.py': {1:None, 2:None},
+ '/home/ned/proj/src/sub/b.py': {3:None},
+ })
+ covdata1.write(suffix='1')
+
+ covdata2 = CoverageData()
+ covdata2.add_line_data({
+ r'c:\ned\test\a.py': {4:None, 5:None},
+ r'c:\ned\test\sub\b.py': {6:None},
+ })
+ covdata2.write(suffix='2')
+
+ covdata3 = CoverageData()
+ aliases = PathAliases()
+ aliases.add("/home/ned/proj/src/", "./")
+ aliases.add(r"c:\ned\test", "./")
+ covdata3.combine_parallel_data(aliases=aliases)
+ self.assert_summary(covdata3, { './a.py':4, './sub/b.py':2 }, fullpath=True)
+ self.assert_measured_files(covdata3, [ './a.py', './sub/b.py' ])
diff --git a/test/test_files.py b/test/test_files.py
index 9cbaf9c..4673add 100644
--- a/test/test_files.py
+++ b/test/test_files.py
@@ -3,8 +3,9 @@
import os, sys
from coverage.files import FileLocator, TreeMatcher, FnmatchMatcher
-from coverage.files import find_python_files
+from coverage.files import PathAliases, find_python_files
from coverage.backward import set # pylint: disable=W0622
+from coverage.misc import CoverageException
sys.path.insert(0, os.path.split(__file__)[0]) # Force relative import for Py3k
from coveragetest import CoverageTest
@@ -72,6 +73,63 @@ class MatcherTest(CoverageTest):
self.assertFalse(fnm.match(fl.canonical_filename(file5)))
+class PathAliasesTest(CoverageTest):
+ def test_noop(self):
+ aliases = PathAliases()
+ self.assertEqual(aliases.map('/ned/home/a.py'), '/ned/home/a.py')
+
+ def test_nomatch(self):
+ aliases = PathAliases()
+ aliases.add('/ned/home/*/src', './mysrc')
+ self.assertEqual(aliases.map('/ned/home/foo/a.py'), '/ned/home/foo/a.py')
+
+ def test_wildcard(self):
+ aliases = PathAliases()
+ aliases.add('/ned/home/*/src', './mysrc')
+ self.assertEqual(aliases.map('/ned/home/foo/src/a.py'), './mysrc/a.py')
+ aliases = PathAliases()
+ aliases.add('/ned/home/*/src/', './mysrc')
+ self.assertEqual(aliases.map('/ned/home/foo/src/a.py'), './mysrc/a.py')
+
+ def test_no_accidental_match(self):
+ aliases = PathAliases()
+ aliases.add('/ned/home/*/src', './mysrc')
+ self.assertEqual(aliases.map('/ned/home/foo/srcetc'), '/ned/home/foo/srcetc')
+
+ def test_multiple_patterns(self):
+ aliases = PathAliases()
+ aliases.add('/ned/home/*/src', './mysrc')
+ aliases.add('/ned/lib/*/libsrc', './mylib')
+ self.assertEqual(aliases.map('/ned/home/foo/src/a.py'), './mysrc/a.py')
+ self.assertEqual(aliases.map('/ned/lib/foo/libsrc/a.py'), './mylib/a.py')
+
+ def test_cant_have_wildcard_at_end(self):
+ aliases = PathAliases()
+ self.assertRaisesRegexp(
+ CoverageException, "Pattern must not end with wildcards.",
+ aliases.add, "/ned/home/*", "fooey"
+ )
+ self.assertRaisesRegexp(
+ CoverageException, "Pattern must not end with wildcards.",
+ aliases.add, "/ned/home/*/", "fooey"
+ )
+ self.assertRaisesRegexp(
+ CoverageException, "Pattern must not end with wildcards.",
+ aliases.add, "/ned/home/*/*/", "fooey"
+ )
+
+ def test_paths_are_os_corrected(self):
+ aliases = PathAliases()
+ aliases.add('/ned/home/*/src', './mysrc')
+ aliases.add(r'c:\ned\foo\src', './mysrc')
+ self.assertEqual(aliases.map(r'C:\Ned\foo\src\sub\a.py'), './mysrc/sub/a.py')
+
+ aliases = PathAliases()
+ aliases.add('/ned/home/*/src', r'.\mysrc')
+ aliases.add(r'c:\ned\foo\src', r'.\mysrc')
+ self.assertEqual(aliases.map(r'/ned/home/foo/src/sub/a.py'), r'.\mysrc\sub\a.py')
+
+
class FindPythonFilesTest(CoverageTest):
"""Tests of `find_python_files`."""