diff options
-rw-r--r-- | coverage/data.py | 12 | ||||
-rw-r--r-- | coverage/files.py | 78 | ||||
-rw-r--r-- | test/test_data.py | 29 | ||||
-rw-r--r-- | test/test_files.py | 60 |
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`.""" |