Skip to content

Commit

Permalink
Report data file errors in more detail: include file and directory pa…
Browse files Browse the repository at this point in the history
…ths, and make helpful suggestions
  • Loading branch information
bartfeenstra committed May 13, 2024
1 parent 5c70761 commit 49fcc01
Show file tree
Hide file tree
Showing 13 changed files with 88 additions and 33 deletions.
11 changes: 7 additions & 4 deletions coverage/cmdline.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import sys
import textwrap
import traceback
from contextlib import suppress

from typing import cast, Any, NoReturn

Expand All @@ -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__
Expand Down Expand Up @@ -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":
Expand Down
21 changes: 13 additions & 8 deletions coverage/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -82,12 +82,15 @@ 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.
Expand Down Expand Up @@ -129,10 +132,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 None

file_hashes = set()
combined_any = False
Expand Down Expand Up @@ -190,7 +195,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:
Expand Down
46 changes: 46 additions & 0 deletions coverage/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@

from __future__ import annotations

import os.path


def _message_append_combine_hint(message: str, is_combining: bool) -> str:
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
Expand All @@ -24,11 +33,48 @@ 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
Expand Down
4 changes: 2 additions & 2 deletions coverage/html.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import coverage
from coverage.data import CoverageData, add_data_to_hash
from coverage.exceptions import NoDataError
from coverage.exceptions import NoDataError, DataFileOrDirectoryNotFoundError
from coverage.files import flat_rootname
from coverage.misc import (
ensure_dir, file_be_gone, Hasher, isolate_module, format_local_datetime,
Expand Down Expand Up @@ -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 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()
Expand Down
6 changes: 3 additions & 3 deletions coverage/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, NoDataError, DataFileOrDirectoryNotFoundError
from coverage.misc import human_sorted_items
from coverage.plugin import FileReporter
from coverage.report_core import get_analysis_to_report
Expand Down Expand Up @@ -181,8 +182,7 @@ def report(self, morfs: Iterable[TMorf] | None, outfile: IO[str] | None = None)
for fr, analysis in get_analysis_to_report(self.coverage, morfs):
self.report_one_file(fr, analysis)

if not self.total.n_files and not self.skipped_count:
raise NoDataError("No data to report.")
if not self.total.n_files and not self.skipped_count: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)
Expand Down
5 changes: 3 additions & 2 deletions coverage/report_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 NoDataError, 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
Expand Down Expand Up @@ -93,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 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:
Expand Down
8 changes: 4 additions & 4 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ 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 combine` must be run first\.$"):
cov.report()

def test_completely_zero_reporting(self) -> None:
Expand Down Expand Up @@ -446,7 +446,7 @@ 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 combine` must be run first.$"):
cov2.combine(strict=True, keep=False)

cov3 = coverage.Coverage()
Expand Down Expand Up @@ -1326,7 +1326,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.
Expand Down Expand Up @@ -1376,7 +1376,7 @@ 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 valid coverage information:\n- `(.+?)`\n- `(.+?)`$"):
cov.combine(strict=True)

warn_rx = re.compile(
Expand Down
6 changes: 3 additions & 3 deletions tests/test_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -1635,19 +1635,19 @@ 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 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 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 combine` must be run first\.$"):
self.command_line("xml")
self.assert_doesnt_exist("coverage.xml")
2 changes: 1 addition & 1 deletion tests/test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'])

Expand Down
2 changes: 1 addition & 1 deletion tests/test_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ 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 combine` must be run first\.$"):
cov.html_report()

def test_execed_liar_ignored(self) -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -1143,7 +1143,7 @@ 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 combine` must be run first\.", out)
assert st == 1


Expand Down
6 changes: 3 additions & 3 deletions tests/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ 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 combine` must be run first\.$"):
self.get_report(cov, skip_covered=True)
self.assert_doesnt_exist(".coverage")

Expand Down Expand Up @@ -716,7 +716,7 @@ 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 combine` must be run first\.$"):
with pytest.warns(Warning) as warns:
self.get_report(cov, morfs=["mycode.py"], ignore_errors=True)
assert_coverage_warnings(
Expand All @@ -733,7 +733,7 @@ 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 combine` must be run first\.$"):
self.get_report(cov, morfs=["mycode.html"])

def test_report_no_extension(self) -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/test_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ 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 combine` must be run first\.$"):
self.run_xml_report()
self.assert_doesnt_exist("coverage.xml")
self.assert_doesnt_exist(".coverage")
Expand Down

0 comments on commit 49fcc01

Please sign in to comment.