From e1dd1184aecf4a9aee5425b487e67dcfd734d608 Mon Sep 17 00:00:00 2001
From: Bart Feenstra <bart@mynameisbart.com>
Date: Mon, 13 May 2024 17:15:53 +0100
Subject: [PATCH 1/3] Report data file errors in more detail: include file and
 directory paths, and make helpful suggestions

---
 coverage/cmdline.py     | 11 +++++---
 coverage/data.py        | 23 ++++++++++------
 coverage/exceptions.py  | 60 +++++++++++++++++++++++++++++++++++++++++
 coverage/html.py        |  6 +++--
 coverage/report.py      |  7 +++--
 coverage/report_core.py |  7 +++--
 tests/test_api.py       | 26 +++++++++++++++---
 tests/test_coverage.py  | 24 ++++++++++++++---
 tests/test_data.py      |  2 +-
 tests/test_html.py      |  8 +++++-
 tests/test_process.py   |  8 +++++-
 tests/test_report.py    | 24 ++++++++++++++---
 tests/test_xml.py       |  8 +++++-
 13 files changed, 182 insertions(+), 32 deletions(-)

diff --git a/coverage/cmdline.py b/coverage/cmdline.py
index 9f9c06559..1f254e004 100644
--- a/coverage/cmdline.py
+++ b/coverage/cmdline.py
@@ -13,6 +13,7 @@
 import sys
 import textwrap
 import traceback
+from contextlib import suppress
 
 from typing import cast, Any, NoReturn
 
@@ -24,7 +25,8 @@
 from coverage.control import DEFAULT_DATAFILE
 from coverage.data import combinable_files, debug_data_file
 from coverage.debug import info_header, short_stack, write_formatted_info
-from coverage.exceptions import _BaseCoverageException, _ExceptionDuringRun, NoSource
+from coverage.exceptions import _BaseCoverageException, _ExceptionDuringRun, NoSource, \
+    NoDataFilesFoundError
 from coverage.execfile import PyRunner
 from coverage.results import display_covered, should_fail_under
 from coverage.version import __url__
@@ -882,9 +884,10 @@ def do_debug(self, args: list[str]) -> int:
             print(info_header("data"))
             data_file = self.coverage.config.data_file
             debug_data_file(data_file)
-            for filename in combinable_files(data_file):
-                print("-----")
-                debug_data_file(filename)
+            with suppress(NoDataFilesFoundError):
+                for filename in combinable_files(data_file):
+                    print("-----")
+                    debug_data_file(filename)
         elif args[0] == "config":
             write_formatted_info(print, "config", self.coverage.config.debug_info())
         elif args[0] == "premain":
diff --git a/coverage/data.py b/coverage/data.py
index 9513adfca..10c5b81ad 100644
--- a/coverage/data.py
+++ b/coverage/data.py
@@ -15,10 +15,10 @@
 import glob
 import hashlib
 import os.path
-
 from typing import Callable, Iterable
 
-from coverage.exceptions import CoverageException, NoDataError
+from coverage.exceptions import CoverageException, DataFileOrDirectoryNotFoundError, \
+    NoDataFilesFoundError, UnusableDataFilesError
 from coverage.files import PathAliases
 from coverage.misc import Hasher, file_be_gone, human_sorted, plural
 from coverage.sqldata import CoverageData
@@ -82,12 +82,17 @@ def combinable_files(data_file: str, data_paths: Iterable[str] | None = None) ->
             pattern = glob.escape(os.path.join(os.path.abspath(p), local)) +".*"
             files_to_combine.extend(glob.glob(pattern))
         else:
-            raise NoDataError(f"Couldn't combine from non-existent path '{p}'")
+            raise DataFileOrDirectoryNotFoundError.new_for_data_file_or_directory(
+                p, is_combining=True
+            )
 
     # SQLite might have made journal files alongside our database files.
     # We never want to combine those.
     files_to_combine = [fnm for fnm in files_to_combine if not fnm.endswith("-journal")]
 
+    if not files_to_combine:
+        raise NoDataFilesFoundError.new_for_data_directory(data_dir)
+
     # Sorting isn't usually needed, since it shouldn't matter what order files
     # are combined, but sorting makes tests more predictable, and makes
     # debugging more understandable when things go wrong.
@@ -129,10 +134,12 @@ def combine_parallel_data(
     `message` is a function to use for printing messages to the user.
 
     """
-    files_to_combine = combinable_files(data.base_filename(), data_paths)
-
-    if strict and not files_to_combine:
-        raise NoDataError("No data to combine")
+    try:
+        files_to_combine = combinable_files(data.base_filename(), data_paths)
+    except NoDataFilesFoundError:
+        if strict:
+            raise
+        return
 
     file_hashes = set()
     combined_any = False
@@ -190,7 +197,7 @@ def combine_parallel_data(
             file_be_gone(f)
 
     if strict and not combined_any:
-        raise NoDataError("No usable data files")
+        raise UnusableDataFilesError.new_for_data_files(*files_to_combine)
 
 
 def debug_data_file(filename: str) -> None:
diff --git a/coverage/exceptions.py b/coverage/exceptions.py
index ecd1b5e64..651afb856 100644
--- a/coverage/exceptions.py
+++ b/coverage/exceptions.py
@@ -5,6 +5,16 @@
 
 from __future__ import annotations
 
+import os.path
+
+
+def _message_append_combine_hint(message: str, is_combining: bool) -> str:
+    """Append information about the combine command to error messages."""
+    if not is_combining:
+        message += " Perhaps `coverage combine` must be run first."
+    return message
+
+
 class _BaseCoverageException(Exception):
     """The base-base of all Coverage exceptions."""
     pass
@@ -24,11 +34,61 @@ class DataError(CoverageException):
     """An error in using a data file."""
     pass
 
+
 class NoDataError(CoverageException):
     """We didn't have data to work with."""
     pass
 
 
+class DataFileOrDirectoryNotFoundError(NoDataError):
+    """A data file or data directory could be found."""
+    @classmethod
+    def new_for_data_file_or_directory(
+        cls, data_file_or_directory_path: str, *, is_combining: bool = False
+    ) -> 'DataFileOrDirectoryNotFoundError':
+        """
+        Create a new instance.
+        """
+        message = (
+            f"The data file or directory `{os.path.abspath(data_file_or_directory_path)}` could not"
+            " be found."
+        )
+        return cls(_message_append_combine_hint(message, is_combining))
+
+
+class NoDataFilesFoundError(NoDataError):
+    """No data files could be found in a data directory."""
+    @classmethod
+    def new_for_data_directory(
+        cls, data_directory_path: str, *, is_combining: bool = False
+    ) -> 'NoDataFilesFoundError':
+        """
+        Create a new instance.
+        """
+        message = (
+            f"The data directory `{os.path.abspath(data_directory_path)}` does not contain any data"
+            " files."
+        )
+        return cls(_message_append_combine_hint(message, is_combining))
+
+
+class UnusableDataFilesError(NoDataError):
+    """The given data files are unusable."""
+    @classmethod
+    def new_for_data_files(cls, *data_file_paths: str) -> 'UnusableDataFilesError':
+        """
+        Create a new instance.
+        """
+        message = (
+            "The following data files are unusable, perhaps because they do not contain valid"
+            " coverage information:"
+        )
+        for data_file_path in data_file_paths:
+            message += f"\n- `{os.path.abspath(data_file_path)}`"
+
+        return cls(message)
+
+
 class NoSource(CoverageException):
     """We couldn't find the source for a module."""
     pass
diff --git a/coverage/html.py b/coverage/html.py
index 51a7f9419..2619381f3 100644
--- a/coverage/html.py
+++ b/coverage/html.py
@@ -19,7 +19,7 @@
 
 import coverage
 from coverage.data import CoverageData, add_data_to_hash
-from coverage.exceptions import NoDataError
+from coverage.exceptions import DataFileOrDirectoryNotFoundError
 from coverage.files import flat_rootname
 from coverage.misc import (
     ensure_dir, file_be_gone, Hasher, isolate_module, format_local_datetime,
@@ -317,7 +317,9 @@ def report(self, morfs: Iterable[TMorf] | None) -> float:
                 file_be_gone(os.path.join(self.directory, ftr.html_filename))
 
         if not have_data:
-            raise NoDataError("No data to report.")
+            raise DataFileOrDirectoryNotFoundError.new_for_data_file_or_directory(
+                os.path.dirname(self.coverage.get_data().base_filename())
+            )
 
         self.make_directory()
         self.make_local_static_report_files()
diff --git a/coverage/report.py b/coverage/report.py
index 42f7b5aec..6b120b5fb 100644
--- a/coverage/report.py
+++ b/coverage/report.py
@@ -5,11 +5,12 @@
 
 from __future__ import annotations
 
+import os
 import sys
 
 from typing import Any, IO, Iterable, TYPE_CHECKING
 
-from coverage.exceptions import ConfigError, NoDataError
+from coverage.exceptions import ConfigError, DataFileOrDirectoryNotFoundError
 from coverage.misc import human_sorted_items
 from coverage.plugin import FileReporter
 from coverage.report_core import get_analysis_to_report
@@ -182,7 +183,9 @@ def report(self, morfs: Iterable[TMorf] | None, outfile: IO[str] | None = None)
             self.report_one_file(fr, analysis)
 
         if not self.total.n_files and not self.skipped_count:
-            raise NoDataError("No data to report.")
+            raise DataFileOrDirectoryNotFoundError.new_for_data_file_or_directory(
+                os.path.dirname(self.coverage.get_data().base_filename())
+            )
 
         if self.output_format == "total":
             self.write(self.total.pc_covered_str)
diff --git a/coverage/report_core.py b/coverage/report_core.py
index db0e7b280..ab413ed4b 100644
--- a/coverage/report_core.py
+++ b/coverage/report_core.py
@@ -5,13 +5,14 @@
 
 from __future__ import annotations
 
+import os
 import sys
 
 from typing import (
     Callable, Iterable, Iterator, IO, Protocol, TYPE_CHECKING,
 )
 
-from coverage.exceptions import NoDataError, NotPython
+from coverage.exceptions import NotPython, DataFileOrDirectoryNotFoundError
 from coverage.files import prep_patterns, GlobMatcher
 from coverage.misc import ensure_dir_for_file, file_be_gone
 from coverage.plugin import FileReporter
@@ -93,7 +94,9 @@ def get_analysis_to_report(
         fr_morfs = [(fr, morf) for (fr, morf) in fr_morfs if not matcher.match(fr.filename)]
 
     if not fr_morfs:
-        raise NoDataError("No data to report.")
+        raise DataFileOrDirectoryNotFoundError.new_for_data_file_or_directory(
+            os.path.dirname(coverage.get_data().base_filename())
+        )
 
     for fr, morf in sorted(fr_morfs):
         try:
diff --git a/tests/test_api.py b/tests/test_api.py
index 9f65166b9..0c1a5b78d 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -300,7 +300,13 @@ def test_empty_reporting(self) -> None:
         # empty summary reports raise exception, just like the xml report
         cov = coverage.Coverage()
         cov.erase()
-        with pytest.raises(NoDataError, match="No data to report."):
+        with pytest.raises(
+            NoDataError,
+            match=(
+                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
+                r"combine` must be run first\.$"
+            )
+        ):
             cov.report()
 
     def test_completely_zero_reporting(self) -> None:
@@ -446,7 +452,13 @@ def test_combining_twice(self) -> None:
         self.assert_exists(".coverage")
 
         cov2 = coverage.Coverage()
-        with pytest.raises(NoDataError, match=r"No data to combine"):
+        with pytest.raises(
+            NoDataError,
+            match=(
+                r"^The data directory `(.+?)` does not contain any data files. Perhaps `coverage "
+                r"combine` must be run first.$"
+            )
+        ):
             cov2.combine(strict=True, keep=False)
 
         cov3 = coverage.Coverage()
@@ -1326,7 +1338,7 @@ def test_combine_parallel_data(self) -> None:
         # Running combine again should fail, because there are no parallel data
         # files to combine.
         cov = coverage.Coverage()
-        with pytest.raises(NoDataError, match=r"No data to combine"):
+        with pytest.raises(NoDataError):
             cov.combine(strict=True)
 
         # And the originally combined data is still there.
@@ -1376,7 +1388,13 @@ def test_combine_no_usable_files(self) -> None:
         # Combine the parallel coverage data files into .coverage, but nothing is readable.
         cov = coverage.Coverage()
         with pytest.warns(Warning) as warns:
-            with pytest.raises(NoDataError, match=r"No usable data files"):
+            with pytest.raises(
+                NoDataError,
+                match=(
+                    r"^The following data files are unusable, perhaps because they do not contain "
+                    r"valid coverage information:\n- `(.+?)`\n- `(.+?)`$"
+                )
+            ):
                 cov.combine(strict=True)
 
         warn_rx = re.compile(
diff --git a/tests/test_coverage.py b/tests/test_coverage.py
index d50971120..8386baf16 100644
--- a/tests/test_coverage.py
+++ b/tests/test_coverage.py
@@ -1635,19 +1635,37 @@ class ReportingTest(CoverageTest):
     def test_no_data_to_report_on_annotate(self) -> None:
         # Reporting with no data produces a nice message and no output
         # directory.
-        with pytest.raises(NoDataError, match="No data to report."):
+        with pytest.raises(
+            NoDataError,
+            match=(
+                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
+                r"combine` must be run first\.$"
+            )
+        ):
             self.command_line("annotate -d ann")
         self.assert_doesnt_exist("ann")
 
     def test_no_data_to_report_on_html(self) -> None:
         # Reporting with no data produces a nice message and no output
         # directory.
-        with pytest.raises(NoDataError, match="No data to report."):
+        with pytest.raises(
+            NoDataError,
+            match=(
+                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
+                r"combine` must be run first\.$"
+            )
+        ):
             self.command_line("html -d htmlcov")
         self.assert_doesnt_exist("htmlcov")
 
     def test_no_data_to_report_on_xml(self) -> None:
         # Reporting with no data produces a nice message.
-        with pytest.raises(NoDataError, match="No data to report."):
+        with pytest.raises(
+            NoDataError,
+            match=(
+                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
+                r"combine` must be run first\.$"
+            )
+        ):
             self.command_line("xml")
         self.assert_doesnt_exist("coverage.xml")
diff --git a/tests/test_data.py b/tests/test_data.py
index 1f0bb20dc..67e1045d8 100644
--- a/tests/test_data.py
+++ b/tests/test_data.py
@@ -915,7 +915,7 @@ def test_combining_from_files(self) -> None:
 
     def test_combining_from_nonexistent_directories(self) -> None:
         covdata = DebugCoverageData()
-        msg = "Couldn't combine from non-existent path 'xyzzy'"
+        msg = r"^The data file or directory `(.+?)` could not be found.$"
         with pytest.raises(NoDataError, match=msg):
             combine_parallel_data(covdata, data_paths=['xyzzy'])
 
diff --git a/tests/test_html.py b/tests/test_html.py
index 1a8c71f95..8c28678e3 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -426,7 +426,13 @@ def test_dothtml_not_python(self) -> None:
         self.make_file("innocuous.html", "<h1>This isn't python at all!</h1>")
         cov = coverage.Coverage()
         cov.load()
-        with pytest.raises(NoDataError, match="No data to report."):
+        with pytest.raises(
+            NoDataError,
+            match=(
+                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
+                r"combine` must be run first\.$"
+            )
+        ):
             cov.html_report()
 
     def test_execed_liar_ignored(self) -> None:
diff --git a/tests/test_process.py b/tests/test_process.py
index 27d01c0f0..4e7a5dcd9 100644
--- a/tests/test_process.py
+++ b/tests/test_process.py
@@ -1143,7 +1143,13 @@ class FailUnderNoFilesTest(CoverageTest):
     def test_report(self) -> None:
         self.make_file(".coveragerc", "[report]\nfail_under = 99\n")
         st, out = self.run_command_status("coverage report")
-        assert 'No data to report.' in out
+        assert re.match(
+            (
+                r"The data file or directory `([^`]+?)` could not be found\. Perhaps `coverage "
+                r"combine` must be run first\."
+            ),
+            out
+        )
         assert st == 1
 
 
diff --git a/tests/test_report.py b/tests/test_report.py
index fca027f9b..b10bc769b 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -551,7 +551,13 @@ def foo():
     def test_report_skip_covered_no_data(self) -> None:
         cov = coverage.Coverage()
         cov.load()
-        with pytest.raises(NoDataError, match="No data to report."):
+        with pytest.raises(
+            NoDataError,
+            match=(
+                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
+                r"combine` must be run first\.$"
+            )
+        ):
             self.get_report(cov, skip_covered=True)
         self.assert_doesnt_exist(".coverage")
 
@@ -744,7 +750,13 @@ def test_dotpy_not_python_ignored(self) -> None:
         self.make_data_file(lines={"mycode.py": [1]})
         cov = coverage.Coverage()
         cov.load()
-        with pytest.raises(NoDataError, match="No data to report."):
+        with pytest.raises(
+            NoDataError,
+            match=(
+                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
+                r"combine` must be run first\.$"
+            )
+        ):
             with pytest.warns(Warning) as warns:
                 self.get_report(cov, morfs=["mycode.py"], ignore_errors=True)
         assert_coverage_warnings(
@@ -761,7 +773,13 @@ def test_dothtml_not_python(self) -> None:
         self.make_data_file(lines={"mycode.html": [1]})
         cov = coverage.Coverage()
         cov.load()
-        with pytest.raises(NoDataError, match="No data to report."):
+        with pytest.raises(
+            NoDataError,
+            match=(
+                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
+                r"combine` must be run first\.$"
+            )
+        ):
             self.get_report(cov, morfs=["mycode.html"])
 
     def test_report_no_extension(self) -> None:
diff --git a/tests/test_xml.py b/tests/test_xml.py
index ad915380a..7b76d5c34 100644
--- a/tests/test_xml.py
+++ b/tests/test_xml.py
@@ -145,7 +145,13 @@ def test_config_affects_xml_placement(self) -> None:
 
     def test_no_data(self) -> None:
         # https://github.com/nedbat/coveragepy/issues/210
-        with pytest.raises(NoDataError, match="No data to report."):
+        with pytest.raises(
+            NoDataError,
+            match=(
+                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
+                r"combine` must be run first\.$"
+            )
+        ):
             self.run_xml_report()
         self.assert_doesnt_exist("coverage.xml")
         self.assert_doesnt_exist(".coverage")

From 8bf5006624623f38155c8a75d3a0e42a9c53fbc7 Mon Sep 17 00:00:00 2001
From: Bart Feenstra <bart@mynameisbart.com>
Date: Thu, 16 May 2024 19:23:01 +0100
Subject: [PATCH 2/3] Implement most of @nedbat's feedback

---
 coverage/cmdline.py     |  5 +++--
 coverage/data.py        |  6 +++---
 coverage/exceptions.py  | 22 +++++++++++-----------
 coverage/html.py        |  2 +-
 coverage/report.py      |  2 +-
 coverage/report_core.py |  2 +-
 tests/test_api.py       | 10 +++++-----
 tests/test_coverage.py  | 12 ++++++------
 tests/test_data.py      |  2 +-
 tests/test_html.py      |  4 ++--
 tests/test_process.py   |  4 ++--
 tests/test_report.py    | 12 ++++++------
 tests/test_xml.py       |  4 ++--
 13 files changed, 44 insertions(+), 43 deletions(-)

diff --git a/coverage/cmdline.py b/coverage/cmdline.py
index 1f254e004..efbd57aad 100644
--- a/coverage/cmdline.py
+++ b/coverage/cmdline.py
@@ -25,8 +25,9 @@
 from coverage.control import DEFAULT_DATAFILE
 from coverage.data import combinable_files, debug_data_file
 from coverage.debug import info_header, short_stack, write_formatted_info
-from coverage.exceptions import _BaseCoverageException, _ExceptionDuringRun, NoSource, \
-    NoDataFilesFoundError
+from coverage.exceptions import (
+    _BaseCoverageException, _ExceptionDuringRun, NoSource, NoDataFilesFoundError,
+)
 from coverage.execfile import PyRunner
 from coverage.results import display_covered, should_fail_under
 from coverage.version import __url__
diff --git a/coverage/data.py b/coverage/data.py
index 10c5b81ad..299c66e76 100644
--- a/coverage/data.py
+++ b/coverage/data.py
@@ -82,7 +82,7 @@ def combinable_files(data_file: str, data_paths: Iterable[str] | None = None) ->
             pattern = glob.escape(os.path.join(os.path.abspath(p), local)) +".*"
             files_to_combine.extend(glob.glob(pattern))
         else:
-            raise DataFileOrDirectoryNotFoundError.new_for_data_file_or_directory(
+            raise DataFileOrDirectoryNotFoundError.new(
                 p, is_combining=True
             )
 
@@ -91,7 +91,7 @@ def combinable_files(data_file: str, data_paths: Iterable[str] | None = None) ->
     files_to_combine = [fnm for fnm in files_to_combine if not fnm.endswith("-journal")]
 
     if not files_to_combine:
-        raise NoDataFilesFoundError.new_for_data_directory(data_dir)
+        raise NoDataFilesFoundError.new(data_dir)
 
     # Sorting isn't usually needed, since it shouldn't matter what order files
     # are combined, but sorting makes tests more predictable, and makes
@@ -197,7 +197,7 @@ def combine_parallel_data(
             file_be_gone(f)
 
     if strict and not combined_any:
-        raise UnusableDataFilesError.new_for_data_files(*files_to_combine)
+        raise UnusableDataFilesError.new(*files_to_combine)
 
 
 def debug_data_file(filename: str) -> None:
diff --git a/coverage/exceptions.py b/coverage/exceptions.py
index 651afb856..450e3e040 100644
--- a/coverage/exceptions.py
+++ b/coverage/exceptions.py
@@ -11,7 +11,7 @@
 def _message_append_combine_hint(message: str, is_combining: bool) -> str:
     """Append information about the combine command to error messages."""
     if not is_combining:
-        message += " Perhaps `coverage combine` must be run first."
+        message += " Perhaps 'coverage combine' must be run first."
     return message
 
 
@@ -43,15 +43,15 @@ class NoDataError(CoverageException):
 class DataFileOrDirectoryNotFoundError(NoDataError):
     """A data file or data directory could be found."""
     @classmethod
-    def new_for_data_file_or_directory(
+    def new(
         cls, data_file_or_directory_path: str, *, is_combining: bool = False
-    ) -> 'DataFileOrDirectoryNotFoundError':
+    ) -> DataFileOrDirectoryNotFoundError:
         """
         Create a new instance.
         """
         message = (
-            f"The data file or directory `{os.path.abspath(data_file_or_directory_path)}` could not"
-            " be found."
+            f"The data file or directory '{os.path.abspath(data_file_or_directory_path)}' could not"
+            + " be found."
         )
         return cls(_message_append_combine_hint(message, is_combining))
 
@@ -59,15 +59,15 @@ def new_for_data_file_or_directory(
 class NoDataFilesFoundError(NoDataError):
     """No data files could be found in a data directory."""
     @classmethod
-    def new_for_data_directory(
+    def new(
         cls, data_directory_path: str, *, is_combining: bool = False
     ) -> 'NoDataFilesFoundError':
         """
         Create a new instance.
         """
         message = (
-            f"The data directory `{os.path.abspath(data_directory_path)}` does not contain any data"
-            " files."
+            f"The data directory '{os.path.abspath(data_directory_path)}' does not contain any data"
+            + " files."
         )
         return cls(_message_append_combine_hint(message, is_combining))
 
@@ -75,16 +75,16 @@ def new_for_data_directory(
 class UnusableDataFilesError(NoDataError):
     """The given data files are unusable."""
     @classmethod
-    def new_for_data_files(cls, *data_file_paths: str) -> 'UnusableDataFilesError':
+    def new(cls, *data_file_paths: str) -> 'UnusableDataFilesError':
         """
         Create a new instance.
         """
         message = (
             "The following data files are unusable, perhaps because they do not contain valid"
-            " coverage information:"
+            + " coverage information:"
         )
         for data_file_path in data_file_paths:
-            message += f"\n- `{os.path.abspath(data_file_path)}`"
+            message += f"\n- '{os.path.abspath(data_file_path)}'"
 
         return cls(message)
 
diff --git a/coverage/html.py b/coverage/html.py
index 2619381f3..c1f3bf8be 100644
--- a/coverage/html.py
+++ b/coverage/html.py
@@ -317,7 +317,7 @@ def report(self, morfs: Iterable[TMorf] | None) -> float:
                 file_be_gone(os.path.join(self.directory, ftr.html_filename))
 
         if not have_data:
-            raise DataFileOrDirectoryNotFoundError.new_for_data_file_or_directory(
+            raise DataFileOrDirectoryNotFoundError.new(
                 os.path.dirname(self.coverage.get_data().base_filename())
             )
 
diff --git a/coverage/report.py b/coverage/report.py
index 6b120b5fb..97881af8f 100644
--- a/coverage/report.py
+++ b/coverage/report.py
@@ -183,7 +183,7 @@ def report(self, morfs: Iterable[TMorf] | None, outfile: IO[str] | None = None)
             self.report_one_file(fr, analysis)
 
         if not self.total.n_files and not self.skipped_count:
-            raise DataFileOrDirectoryNotFoundError.new_for_data_file_or_directory(
+            raise DataFileOrDirectoryNotFoundError.new(
                 os.path.dirname(self.coverage.get_data().base_filename())
             )
 
diff --git a/coverage/report_core.py b/coverage/report_core.py
index ab413ed4b..3c9182339 100644
--- a/coverage/report_core.py
+++ b/coverage/report_core.py
@@ -94,7 +94,7 @@ def get_analysis_to_report(
         fr_morfs = [(fr, morf) for (fr, morf) in fr_morfs if not matcher.match(fr.filename)]
 
     if not fr_morfs:
-        raise DataFileOrDirectoryNotFoundError.new_for_data_file_or_directory(
+        raise DataFileOrDirectoryNotFoundError.new(
             os.path.dirname(coverage.get_data().base_filename())
         )
 
diff --git a/tests/test_api.py b/tests/test_api.py
index 0c1a5b78d..63879e2b5 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -303,8 +303,8 @@ def test_empty_reporting(self) -> None:
         with pytest.raises(
             NoDataError,
             match=(
-                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
-                r"combine` must be run first\.$"
+                r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
+                r"combine' must be run first\.$"
             )
         ):
             cov.report()
@@ -455,8 +455,8 @@ def test_combining_twice(self) -> None:
         with pytest.raises(
             NoDataError,
             match=(
-                r"^The data directory `(.+?)` does not contain any data files. Perhaps `coverage "
-                r"combine` must be run first.$"
+                r"^The data directory '(.+?)' does not contain any data files. Perhaps 'coverage "
+                r"combine' must be run first.$"
             )
         ):
             cov2.combine(strict=True, keep=False)
@@ -1392,7 +1392,7 @@ def test_combine_no_usable_files(self) -> None:
                 NoDataError,
                 match=(
                     r"^The following data files are unusable, perhaps because they do not contain "
-                    r"valid coverage information:\n- `(.+?)`\n- `(.+?)`$"
+                    r"valid coverage information:\n- '(.+?)'\n- '(.+?)'$"
                 )
             ):
                 cov.combine(strict=True)
diff --git a/tests/test_coverage.py b/tests/test_coverage.py
index 8386baf16..a5264277b 100644
--- a/tests/test_coverage.py
+++ b/tests/test_coverage.py
@@ -1638,8 +1638,8 @@ def test_no_data_to_report_on_annotate(self) -> None:
         with pytest.raises(
             NoDataError,
             match=(
-                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
-                r"combine` must be run first\.$"
+                r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
+                r"combine' must be run first\.$"
             )
         ):
             self.command_line("annotate -d ann")
@@ -1651,8 +1651,8 @@ def test_no_data_to_report_on_html(self) -> None:
         with pytest.raises(
             NoDataError,
             match=(
-                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
-                r"combine` must be run first\.$"
+                r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
+                r"combine' must be run first\.$"
             )
         ):
             self.command_line("html -d htmlcov")
@@ -1663,8 +1663,8 @@ def test_no_data_to_report_on_xml(self) -> None:
         with pytest.raises(
             NoDataError,
             match=(
-                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
-                r"combine` must be run first\.$"
+                r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
+                r"combine' must be run first\.$"
             )
         ):
             self.command_line("xml")
diff --git a/tests/test_data.py b/tests/test_data.py
index 67e1045d8..bd6d2194b 100644
--- a/tests/test_data.py
+++ b/tests/test_data.py
@@ -915,7 +915,7 @@ def test_combining_from_files(self) -> None:
 
     def test_combining_from_nonexistent_directories(self) -> None:
         covdata = DebugCoverageData()
-        msg = r"^The data file or directory `(.+?)` could not be found.$"
+        msg = r"^The data file or directory '(.+?)' could not be found.$"
         with pytest.raises(NoDataError, match=msg):
             combine_parallel_data(covdata, data_paths=['xyzzy'])
 
diff --git a/tests/test_html.py b/tests/test_html.py
index 8c28678e3..3a0865922 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -429,8 +429,8 @@ def test_dothtml_not_python(self) -> None:
         with pytest.raises(
             NoDataError,
             match=(
-                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
-                r"combine` must be run first\.$"
+                r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
+                r"combine' must be run first\.$"
             )
         ):
             cov.html_report()
diff --git a/tests/test_process.py b/tests/test_process.py
index 4e7a5dcd9..c96539978 100644
--- a/tests/test_process.py
+++ b/tests/test_process.py
@@ -1145,8 +1145,8 @@ def test_report(self) -> None:
         st, out = self.run_command_status("coverage report")
         assert re.match(
             (
-                r"The data file or directory `([^`]+?)` could not be found\. Perhaps `coverage "
-                r"combine` must be run first\."
+                r"The data file or directory '([^']+?)' could not be found\. Perhaps 'coverage "
+                + r"combine' must be run first\."
             ),
             out
         )
diff --git a/tests/test_report.py b/tests/test_report.py
index b10bc769b..393be02ea 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -554,8 +554,8 @@ def test_report_skip_covered_no_data(self) -> None:
         with pytest.raises(
             NoDataError,
             match=(
-                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
-                r"combine` must be run first\.$"
+                r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
+                r"combine' must be run first\.$"
             )
         ):
             self.get_report(cov, skip_covered=True)
@@ -753,8 +753,8 @@ def test_dotpy_not_python_ignored(self) -> None:
         with pytest.raises(
             NoDataError,
             match=(
-                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
-                r"combine` must be run first\.$"
+                r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
+                r"combine' must be run first\.$"
             )
         ):
             with pytest.warns(Warning) as warns:
@@ -776,8 +776,8 @@ def test_dothtml_not_python(self) -> None:
         with pytest.raises(
             NoDataError,
             match=(
-                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
-                r"combine` must be run first\.$"
+                r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
+                r"combine' must be run first\.$"
             )
         ):
             self.get_report(cov, morfs=["mycode.html"])
diff --git a/tests/test_xml.py b/tests/test_xml.py
index 7b76d5c34..ef8ba9dff 100644
--- a/tests/test_xml.py
+++ b/tests/test_xml.py
@@ -148,8 +148,8 @@ def test_no_data(self) -> None:
         with pytest.raises(
             NoDataError,
             match=(
-                r"^The data file or directory `(.+?)` could not be found\. Perhaps `coverage "
-                r"combine` must be run first\.$"
+                r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
+                r"combine' must be run first\.$"
             )
         ):
             self.run_xml_report()

From 42b02a1f3057bcf29bae421de84d89281d688a7b Mon Sep 17 00:00:00 2001
From: Bart Feenstra <bart@mynameisbart.com>
Date: Thu, 16 May 2024 19:27:37 +0100
Subject: [PATCH 3/3] Non-magic string concatenation

---
 tests/test_api.py      | 6 +++---
 tests/test_coverage.py | 6 +++---
 tests/test_html.py     | 2 +-
 tests/test_report.py   | 6 +++---
 tests/test_xml.py      | 2 +-
 5 files changed, 11 insertions(+), 11 deletions(-)

diff --git a/tests/test_api.py b/tests/test_api.py
index 63879e2b5..c07a48eb1 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -304,7 +304,7 @@ def test_empty_reporting(self) -> None:
             NoDataError,
             match=(
                 r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
-                r"combine' must be run first\.$"
+                + r"combine' must be run first\.$"
             )
         ):
             cov.report()
@@ -456,7 +456,7 @@ def test_combining_twice(self) -> None:
             NoDataError,
             match=(
                 r"^The data directory '(.+?)' does not contain any data files. Perhaps 'coverage "
-                r"combine' must be run first.$"
+                + r"combine' must be run first.$"
             )
         ):
             cov2.combine(strict=True, keep=False)
@@ -1392,7 +1392,7 @@ def test_combine_no_usable_files(self) -> None:
                 NoDataError,
                 match=(
                     r"^The following data files are unusable, perhaps because they do not contain "
-                    r"valid coverage information:\n- '(.+?)'\n- '(.+?)'$"
+                    + r"valid coverage information:\n- '(.+?)'\n- '(.+?)'$"
                 )
             ):
                 cov.combine(strict=True)
diff --git a/tests/test_coverage.py b/tests/test_coverage.py
index a5264277b..051a6c217 100644
--- a/tests/test_coverage.py
+++ b/tests/test_coverage.py
@@ -1639,7 +1639,7 @@ def test_no_data_to_report_on_annotate(self) -> None:
             NoDataError,
             match=(
                 r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
-                r"combine' must be run first\.$"
+                + r"combine' must be run first\.$"
             )
         ):
             self.command_line("annotate -d ann")
@@ -1652,7 +1652,7 @@ def test_no_data_to_report_on_html(self) -> None:
             NoDataError,
             match=(
                 r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
-                r"combine' must be run first\.$"
+                + r"combine' must be run first\.$"
             )
         ):
             self.command_line("html -d htmlcov")
@@ -1664,7 +1664,7 @@ def test_no_data_to_report_on_xml(self) -> None:
             NoDataError,
             match=(
                 r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
-                r"combine' must be run first\.$"
+                + r"combine' must be run first\.$"
             )
         ):
             self.command_line("xml")
diff --git a/tests/test_html.py b/tests/test_html.py
index 3a0865922..2767c0532 100644
--- a/tests/test_html.py
+++ b/tests/test_html.py
@@ -430,7 +430,7 @@ def test_dothtml_not_python(self) -> None:
             NoDataError,
             match=(
                 r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
-                r"combine' must be run first\.$"
+                + r"combine' must be run first\.$"
             )
         ):
             cov.html_report()
diff --git a/tests/test_report.py b/tests/test_report.py
index 393be02ea..c81fdfec3 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -555,7 +555,7 @@ def test_report_skip_covered_no_data(self) -> None:
             NoDataError,
             match=(
                 r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
-                r"combine' must be run first\.$"
+                + r"combine' must be run first\.$"
             )
         ):
             self.get_report(cov, skip_covered=True)
@@ -754,7 +754,7 @@ def test_dotpy_not_python_ignored(self) -> None:
             NoDataError,
             match=(
                 r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
-                r"combine' must be run first\.$"
+                + r"combine' must be run first\.$"
             )
         ):
             with pytest.warns(Warning) as warns:
@@ -777,7 +777,7 @@ def test_dothtml_not_python(self) -> None:
             NoDataError,
             match=(
                 r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
-                r"combine' must be run first\.$"
+                + r"combine' must be run first\.$"
             )
         ):
             self.get_report(cov, morfs=["mycode.html"])
diff --git a/tests/test_xml.py b/tests/test_xml.py
index ef8ba9dff..15760bab7 100644
--- a/tests/test_xml.py
+++ b/tests/test_xml.py
@@ -149,7 +149,7 @@ def test_no_data(self) -> None:
             NoDataError,
             match=(
                 r"^The data file or directory '(.+?)' could not be found\. Perhaps 'coverage "
-                r"combine' must be run first\.$"
+                + r"combine' must be run first\.$"
             )
         ):
             self.run_xml_report()