diff --git a/CHANGES.rst b/CHANGES.rst index 2fd7447c7..b1d94dbdc 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -29,6 +29,11 @@ Unreleased - Using ``--format=total`` will write a single total number to the output. This can be useful for making badges or writing status updates. +- Reporting operations now use the ``[paths]`` setting to remap file paths + within a single data file. Combining multiple files still requires the + ``coverage combine`` step, but this simplifies some situations. Closes + `issue 1212`_ and `issue 713`_. + - Combining data files with ``coverage combine`` now quickly hashes the data files to skip files that provide no new information. This can reduce the time needed. Many details affect the results, but for coverage.py's own test @@ -54,6 +59,8 @@ Unreleased - The deprecated ``[run] note`` setting has been completely removed. .. _implicit namespace packages: https://peps.python.org/pep-0420/ +.. _issue 713: https://github.com/nedbat/coveragepy/issues/713 +.. _issue 1212: https://github.com/nedbat/coveragepy/issues/1212 .. _issue 1383: https://github.com/nedbat/coveragepy/issues/1383 .. _issue 1418: https://github.com/nedbat/coveragepy/issues/1418 .. _issue 1421: https://github.com/nedbat/coveragepy/issues/1421 diff --git a/coverage/control.py b/coverage/control.py index 2e58ad85c..c0497478b 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -733,6 +733,18 @@ def save(self): data = self.get_data() data.write() + def _make_aliases(self): + """Create a PathAliases from our configuration.""" + aliases = PathAliases( + debugfn=(self._debug.write if self._debug.should("pathmap") else None), + relative=self.config.relative_files, + ) + for paths in self.config.paths.values(): + result = paths[0] + for pattern in paths[1:]: + aliases.add(pattern, result) + return aliases + def combine(self, data_paths=None, strict=False, keep=False): """Combine together a number of similarly-named coverage data files. @@ -764,18 +776,9 @@ def combine(self, data_paths=None, strict=False, keep=False): self._post_init() self.get_data() - aliases = PathAliases( - debugfn=(self._debug.write if self._debug.should("pathmap") else None), - relative=self.config.relative_files, - ) - for paths in self.config.paths.values(): - result = paths[0] - for pattern in paths[1:]: - aliases.add(pattern, result) - combine_parallel_data( self._data, - aliases=aliases, + aliases=self._make_aliases(), data_paths=data_paths, strict=strict, keep=keep, @@ -925,6 +928,13 @@ def _get_file_reporters(self, morfs=None): file_reporters = [self._get_file_reporter(morf) for morf in morfs] return file_reporters + def _prepare_data_for_reporting(self): + """Re-map data before reporting, to get implicit 'combine' behavior.""" + if self.config.paths: + mapped_data = CoverageData(warn=self._warn, debug=self._debug, no_disk=True) + mapped_data.update(self._data, aliases=self._make_aliases()) + self._data = mapped_data + def report( self, morfs=None, @@ -990,6 +1000,7 @@ def report( The `format` parameter. """ + self._prepare_data_for_reporting() with override_config( self, ignore_errors=ignore_errors, @@ -1034,6 +1045,7 @@ def annotate( print("The annotate command will be removed in a future version.") print("Get in touch if you still use it: ned@nedbatchelder.com") + self._prepare_data_for_reporting() with override_config( self, ignore_errors=ignore_errors, @@ -1083,6 +1095,7 @@ def html_report( changing the files in the report folder. """ + self._prepare_data_for_reporting() with override_config( self, ignore_errors=ignore_errors, @@ -1123,6 +1136,7 @@ def xml_report( Returns a float, the total percentage covered. """ + self._prepare_data_for_reporting() with override_config( self, ignore_errors=ignore_errors, @@ -1157,6 +1171,7 @@ def json_report( .. versionadded:: 5.0 """ + self._prepare_data_for_reporting() with override_config( self, ignore_errors=ignore_errors, @@ -1187,6 +1202,7 @@ def lcov_report( .. versionadded:: 6.3 """ + self._prepare_data_for_reporting() with override_config( self, ignore_errors=ignore_errors, diff --git a/coverage/sqldata.py b/coverage/sqldata.py index ea6b1199f..68663715c 100644 --- a/coverage/sqldata.py +++ b/coverage/sqldata.py @@ -648,7 +648,12 @@ def update(self, other_data, aliases=None): "inner join file on file.id = line_bits.file_id " + "inner join context on context.id = line_bits.context_id" ) - lines = {(files[path], context): numbits for (path, context, numbits) in cur} + lines = {} + for path, context, numbits in cur: + key = (files[path], context) + if key in lines: + numbits = numbits_union(lines[key], numbits) + lines[key] = numbits cur.close() # Get tracer data. diff --git a/doc/config.rst b/doc/config.rst index ba3243a77..1c7e9ea21 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -346,6 +346,10 @@ against the source file found at "src/module.py". If you specify more than one list of paths, they will be considered in order. The first list that has a match will be used. +Remapping will also be done during reporting, but only within the single data +file being reported. Combining multiple files requires the ``combine`` +command. + The ``--debug=pathmap`` option can be used to log details of the re-mapping of paths. See :ref:`the --debug option `. diff --git a/tests/coveragetest.py b/tests/coveragetest.py index 54ae4eb42..56e788533 100644 --- a/tests/coveragetest.py +++ b/tests/coveragetest.py @@ -219,11 +219,14 @@ def check_coverage( return cov - def make_data_file(self, basename=None, suffix=None, lines=None, file_tracers=None): + def make_data_file(self, basename=None, suffix=None, lines=None, arcs=None, file_tracers=None): """Write some data into a coverage data file.""" data = coverage.CoverageData(basename=basename, suffix=suffix) + assert lines is None or arcs is None if lines: data.add_lines(lines) + if arcs: + data.add_arcs(arcs) if file_tracers: data.add_file_tracers(file_tracers) data.write() diff --git a/tests/test_api.py b/tests/test_api.py index 195452323..c2dbefa81 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -23,7 +23,8 @@ from coverage.misc import import_local_file from tests.coveragetest import CoverageTest, TESTS_DIR, UsingModulesMixin -from tests.helpers import assert_count_equal, assert_coverage_warnings +from tests.goldtest import contains, doesnt_contain +from tests.helpers import arcz_to_arcs, assert_count_equal, assert_coverage_warnings from tests.helpers import change_dir, nice_file, os_sep BAD_SQLITE_REGEX = r"file( is encrypted or)? is not a database" @@ -1456,3 +1457,146 @@ def test_combine_parallel_data_keep(self): # After combining, the .coverage file & the original combined file should still be there. self.assert_exists(".coverage") self.assert_file_count(".coverage.*", 2) + + +class ReportMapsPathsTest(CoverageTest): + """Check that reporting implicitly maps paths.""" + + def make_files(self, data, settings=False): + """Create the test files we need for line coverage.""" + src = """\ + if VER == 1: + print("line 2") + if VER == 2: + print("line 4") + if VER == 3: + print("line 6") + """ + self.make_file("src/program.py", src) + self.make_file("ver1/program.py", src) + self.make_file("ver2/program.py", src) + + if data == "line": + self.make_data_file( + lines={ + abs_file("ver1/program.py"): [1, 2, 3, 5], + abs_file("ver2/program.py"): [1, 3, 4, 5], + } + ) + else: + self.make_data_file( + arcs={ + abs_file("ver1/program.py"): arcz_to_arcs(".1 12 23 35 5."), + abs_file("ver2/program.py"): arcz_to_arcs(".1 13 34 45 5."), + } + ) + + if settings: + self.make_file(".coveragerc", """\ + [paths] + source = + src + ver1 + ver2 + """) + + def test_map_paths_during_line_report_without_setting(self): + self.make_files(data="line") + cov = coverage.Coverage() + cov.load() + cov.report(show_missing=True) + expected = textwrap.dedent(os_sep("""\ + Name Stmts Miss Cover Missing + ----------------------------------------------- + ver1/program.py 6 2 67% 4, 6 + ver2/program.py 6 2 67% 2, 6 + ----------------------------------------------- + TOTAL 12 4 67% + """)) + assert expected == self.stdout() + + def test_map_paths_during_line_report(self): + self.make_files(data="line", settings=True) + cov = coverage.Coverage() + cov.load() + cov.report(show_missing=True) + expected = textwrap.dedent(os_sep("""\ + Name Stmts Miss Cover Missing + ---------------------------------------------- + src/program.py 6 1 83% 6 + ---------------------------------------------- + TOTAL 6 1 83% + """)) + assert expected == self.stdout() + + def test_map_paths_during_branch_report_without_setting(self): + self.make_files(data="arcs") + cov = coverage.Coverage(branch=True) + cov.load() + cov.report(show_missing=True) + expected = textwrap.dedent(os_sep("""\ + Name Stmts Miss Branch BrPart Cover Missing + ------------------------------------------------------------- + ver1/program.py 6 2 6 3 58% 1->3, 4, 6 + ver2/program.py 6 2 6 3 58% 2, 3->5, 6 + ------------------------------------------------------------- + TOTAL 12 4 12 6 58% + """)) + assert expected == self.stdout() + + def test_map_paths_during_branch_report(self): + self.make_files(data="arcs", settings=True) + cov = coverage.Coverage(branch=True) + cov.load() + cov.report(show_missing=True) + expected = textwrap.dedent(os_sep("""\ + Name Stmts Miss Branch BrPart Cover Missing + ------------------------------------------------------------ + src/program.py 6 1 6 1 83% 6 + ------------------------------------------------------------ + TOTAL 6 1 6 1 83% + """)) + assert expected == self.stdout() + + def test_map_paths_during_annotate(self): + self.make_files(data="line", settings=True) + cov = coverage.Coverage() + cov.load() + cov.annotate() + self.assert_exists(os_sep("src/program.py,cover")) + self.assert_doesnt_exist(os_sep("ver1/program.py,cover")) + self.assert_doesnt_exist(os_sep("ver2/program.py,cover")) + + def test_map_paths_during_html_report(self): + self.make_files(data="line", settings=True) + cov = coverage.Coverage() + cov.load() + cov.html_report() + contains("htmlcov/index.html", os_sep("src/program.py")) + doesnt_contain("htmlcov/index.html", os_sep("ver1/program.py"), os_sep("ver2/program.py")) + + def test_map_paths_during_xml_report(self): + self.make_files(data="line", settings=True) + cov = coverage.Coverage() + cov.load() + cov.xml_report() + contains("coverage.xml", "src/program.py") + doesnt_contain("coverage.xml", "ver1/program.py", "ver2/program.py") + + def test_map_paths_during_json_report(self): + self.make_files(data="line", settings=True) + cov = coverage.Coverage() + cov.load() + cov.json_report() + def os_sepj(s): + return os_sep(s).replace("\\", r"\\") + contains("coverage.json", os_sepj("src/program.py")) + doesnt_contain("coverage.json", os_sepj("ver1/program.py"), os_sepj("ver2/program.py")) + + def test_map_paths_during_lcov_report(self): + self.make_files(data="line", settings=True) + cov = coverage.Coverage() + cov.load() + cov.lcov_report() + contains("coverage.lcov", os_sep("src/program.py")) + doesnt_contain("coverage.lcov", os_sep("ver1/program.py"), os_sep("ver2/program.py"))