Skip to content

Commit 1c29ef3

Browse files
committed
refactor: specialize exceptions
CoverageException is fine as a base class, but not good to use for raising (and catching sometimes). Introduce specialized exceptions that allow third-party tools to integrate better.
1 parent 342e7da commit 1c29ef3

25 files changed

+147
-115
lines changed

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ Unreleased
3434
- Fix: The HTML report now will not overwrite a .gitignore file that already
3535
exists in the HTML output directory (follow-on for `issue 1244`_).
3636

37+
- API: The exceptions raised by Coverage.py have been specialized, to provide
38+
finer-grained catching of exceptions by third-party code.
39+
3740
- Debug: The `coverage debug data` command will now sniff out combinable data
3841
files, and report on all of them.
3942

coverage/collector.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from coverage import env
1010
from coverage.debug import short_stack
1111
from coverage.disposition import FileDisposition
12-
from coverage.exceptions import CoverageException
12+
from coverage.exceptions import ConfigError
1313
from coverage.misc import human_sorted, isolate_module
1414
from coverage.pytracer import PyTracer
1515

@@ -116,7 +116,7 @@ def __init__(
116116
# We can handle a few concurrency options here, but only one at a time.
117117
these_concurrencies = self.SUPPORTED_CONCURRENCIES.intersection(concurrency)
118118
if len(these_concurrencies) > 1:
119-
raise CoverageException(f"Conflicting concurrency settings: {concurrency}")
119+
raise ConfigError(f"Conflicting concurrency settings: {concurrency}")
120120
self.concurrency = these_concurrencies.pop() if these_concurrencies else ''
121121

122122
try:
@@ -136,9 +136,9 @@ def __init__(
136136
import threading
137137
self.threading = threading
138138
else:
139-
raise CoverageException(f"Don't understand concurrency={concurrency}")
139+
raise ConfigError(f"Don't understand concurrency={concurrency}")
140140
except ImportError as ex:
141-
raise CoverageException(
141+
raise ConfigError(
142142
"Couldn't trace with concurrency={}, the module isn't installed.".format(
143143
self.concurrency,
144144
)
@@ -245,7 +245,7 @@ def _start_tracer(self):
245245
if hasattr(tracer, 'concur_id_func'):
246246
tracer.concur_id_func = self.concur_id_func
247247
elif self.concur_id_func:
248-
raise CoverageException(
248+
raise ConfigError(
249249
"Can't support concurrency={} with {}, only threads are supported".format(
250250
self.concurrency, self.tracer_name(),
251251
)

coverage/config.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import os.path
1111
import re
1212

13-
from coverage.exceptions import CoverageException
13+
from coverage.exceptions import ConfigError
1414
from coverage.misc import contract, isolate_module, substitute_variables
1515

1616
from coverage.tomlconfig import TomlConfigParser, TomlDecodeError
@@ -59,7 +59,7 @@ def options(self, section):
5959
real_section = section_prefix + section
6060
if configparser.RawConfigParser.has_section(self, real_section):
6161
return configparser.RawConfigParser.options(self, real_section)
62-
raise configparser.NoSectionError(section)
62+
raise ConfigError(f"No section: {section!r}")
6363

6464
def get_section(self, section):
6565
"""Get the contents of a section, as a dictionary."""
@@ -83,7 +83,7 @@ def get(self, section, option, *args, **kwargs):
8383
if configparser.RawConfigParser.has_option(self, real_section, option):
8484
break
8585
else:
86-
raise configparser.NoOptionError(option, section)
86+
raise ConfigError(f"No option {option!r} in section: {section!r}")
8787

8888
v = configparser.RawConfigParser.get(self, real_section, option, *args, **kwargs)
8989
v = substitute_variables(v, os.environ)
@@ -123,7 +123,7 @@ def getregexlist(self, section, option):
123123
try:
124124
re.compile(value)
125125
except re.error as e:
126-
raise CoverageException(
126+
raise ConfigError(
127127
f"Invalid [{section}].{option} value {value!r}: {e}"
128128
) from e
129129
if value:
@@ -272,7 +272,7 @@ def from_file(self, filename, warn, our_file):
272272
try:
273273
files_read = cp.read(filename)
274274
except (configparser.Error, TomlDecodeError) as err:
275-
raise CoverageException(f"Couldn't read config file {filename}: {err}") from err
275+
raise ConfigError(f"Couldn't read config file {filename}: {err}") from err
276276
if not files_read:
277277
return False
278278

@@ -285,7 +285,7 @@ def from_file(self, filename, warn, our_file):
285285
if was_set:
286286
any_set = True
287287
except ValueError as err:
288-
raise CoverageException(f"Couldn't read config file {filename}: {err}") from err
288+
raise ConfigError(f"Couldn't read config file {filename}: {err}") from err
289289

290290
# Check that there are no unrecognized options.
291291
all_options = collections.defaultdict(set)
@@ -443,7 +443,7 @@ def set_option(self, option_name, value):
443443
return
444444

445445
# If we get here, we didn't find the option.
446-
raise CoverageException(f"No such option: {option_name!r}")
446+
raise ConfigError(f"No such option: {option_name!r}")
447447

448448
def get_option(self, option_name):
449449
"""Get an option from the configuration.
@@ -471,7 +471,7 @@ def get_option(self, option_name):
471471
return self.plugin_options.get(plugin_name, {}).get(key)
472472

473473
# If we get here, we didn't find the option.
474-
raise CoverageException(f"No such option: {option_name!r}")
474+
raise ConfigError(f"No such option: {option_name!r}")
475475

476476
def post_process_file(self, path):
477477
"""Make final adjustments to a file path to make it usable."""
@@ -546,7 +546,7 @@ def read_coverage_config(config_file, warn, **kwargs):
546546
if config_read:
547547
break
548548
if specified_file:
549-
raise CoverageException(f"Couldn't read {fname!r} as a config file")
549+
raise ConfigError(f"Couldn't read {fname!r} as a config file")
550550

551551
# $set_env.py: COVERAGE_DEBUG - Options for --debug.
552552
# 3) from environment variables:

coverage/control.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from coverage.data import CoverageData, combine_parallel_data
2222
from coverage.debug import DebugControl, short_stack, write_formatted_info
2323
from coverage.disposition import disposition_debug_msg
24-
from coverage.exceptions import CoverageException, CoverageWarning
24+
from coverage.exceptions import ConfigError, CoverageException, CoverageWarning, PluginError
2525
from coverage.files import PathAliases, abs_file, relative_filename, set_relative_directory
2626
from coverage.html import HtmlReporter
2727
from coverage.inorout import InOrOut
@@ -79,6 +79,8 @@ class Coverage:
7979
not part of the public API. They might stop working at any point. Please
8080
limit yourself to documented methods to avoid problems.
8181
82+
Methods can raise any of the exceptions described in :ref:`api_exceptions`.
83+
8284
"""
8385

8486
# The stack of started Coverage instances.
@@ -449,7 +451,7 @@ def _init_for_start(self):
449451
concurrency = self.config.concurrency or ()
450452
if "multiprocessing" in concurrency:
451453
if not patch_multiprocessing:
452-
raise CoverageException( # pragma: only jython
454+
raise ConfigError( # pragma: only jython
453455
"multiprocessing is not supported on this Python"
454456
)
455457
patch_multiprocessing(rcfile=self.config.config_file)
@@ -460,7 +462,7 @@ def _init_for_start(self):
460462
elif dycon == "test_function":
461463
context_switchers = [should_start_context_test_function]
462464
else:
463-
raise CoverageException(f"Don't understand dynamic_context setting: {dycon!r}")
465+
raise ConfigError(f"Don't understand dynamic_context setting: {dycon!r}")
464466

465467
context_switchers.extend(
466468
plugin.dynamic_context for plugin in self._plugins.context_switchers
@@ -835,7 +837,7 @@ def _get_file_reporter(self, morf):
835837
if plugin:
836838
file_reporter = plugin.file_reporter(mapped_morf)
837839
if file_reporter is None:
838-
raise CoverageException(
840+
raise PluginError(
839841
"Plugin {!r} did not provide a file reporter for {!r}.".format(
840842
plugin._coverage_plugin_name, morf
841843
)

coverage/data.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import glob
1414
import os.path
1515

16-
from coverage.exceptions import CoverageException
16+
from coverage.exceptions import CoverageException, NoDataError
1717
from coverage.misc import file_be_gone
1818
from coverage.sqldata import CoverageData
1919

@@ -72,7 +72,7 @@ def combinable_files(data_file, data_paths=None):
7272
pattern = os.path.join(os.path.abspath(p), f"{local}.*")
7373
files_to_combine.extend(glob.glob(pattern))
7474
else:
75-
raise CoverageException(f"Couldn't combine from non-existent path '{p}'")
75+
raise NoDataError(f"Couldn't combine from non-existent path '{p}'")
7676
return files_to_combine
7777

7878

@@ -107,7 +107,7 @@ def combine_parallel_data(
107107
files_to_combine = combinable_files(data.base_filename(), data_paths)
108108

109109
if strict and not files_to_combine:
110-
raise CoverageException("No data to combine")
110+
raise NoDataError("No data to combine")
111111

112112
files_combined = 0
113113
for f in files_to_combine:
@@ -138,4 +138,4 @@ def combine_parallel_data(
138138
file_be_gone(f)
139139

140140
if strict and not files_combined:
141-
raise CoverageException("No usable data files")
141+
raise NoDataError("No usable data files")

coverage/exceptions.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,26 @@
55

66

77
class _BaseCoverageException(Exception):
8-
"""The base of all Coverage exceptions."""
8+
"""The base-base of all Coverage exceptions."""
99
pass
1010

1111

1212
class CoverageException(_BaseCoverageException):
13-
"""An exception raised by a coverage.py function."""
13+
"""The base class of all exceptions raised by Coverage.py."""
14+
pass
15+
16+
17+
class ConfigError(_BaseCoverageException):
18+
"""A problem with a config file, or a value in one."""
19+
pass
20+
21+
22+
class DataError(CoverageException):
23+
"""An error in using a data file."""
24+
pass
25+
26+
class NoDataError(CoverageException):
27+
"""We didn't have data to work with."""
1428
pass
1529

1630

@@ -29,6 +43,11 @@ class NotPython(CoverageException):
2943
pass
3044

3145

46+
class PluginError(CoverageException):
47+
"""A plugin misbehaved."""
48+
pass
49+
50+
3251
class _ExceptionDuringRun(CoverageException):
3352
"""An exception happened while running customer code.
3453

coverage/files.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import sys
1414

1515
from coverage import env
16-
from coverage.exceptions import CoverageException
16+
from coverage.exceptions import ConfigError
1717
from coverage.misc import contract, human_sorted, isolate_module, join_regex
1818

1919

@@ -356,7 +356,7 @@ def add(self, pattern, result):
356356

357357
# The pattern can't end with a wildcard component.
358358
if pattern.endswith("*"):
359-
raise CoverageException("Pattern must not end with wildcards.")
359+
raise ConfigError("Pattern must not end with wildcards.")
360360

361361
# The pattern is meant to match a filepath. Let's make it absolute
362362
# unless it already is, or is meant to match any prefix.

coverage/html.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import coverage
1414
from coverage.data import add_data_to_hash
15-
from coverage.exceptions import CoverageException
15+
from coverage.exceptions import NoDataError
1616
from coverage.files import flat_rootname
1717
from coverage.misc import ensure_dir, file_be_gone, Hasher, isolate_module, format_local_datetime
1818
from coverage.misc import human_sorted, plural
@@ -208,7 +208,7 @@ def report(self, morfs):
208208
self.html_file(fr, analysis)
209209

210210
if not self.all_files_nums:
211-
raise CoverageException("No data to report.")
211+
raise NoDataError("No data to report.")
212212

213213
self.totals = sum(self.all_files_nums)
214214

coverage/inorout.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from coverage import env
1717
from coverage.disposition import FileDisposition, disposition_init
18-
from coverage.exceptions import CoverageException
18+
from coverage.exceptions import CoverageException, PluginError
1919
from coverage.files import TreeMatcher, FnmatchMatcher, ModuleMatcher
2020
from coverage.files import prep_patterns, find_python_files, canonical_filename
2121
from coverage.misc import sys_modules_saved
@@ -392,7 +392,7 @@ def nope(disp, reason):
392392

393393
if not disp.has_dynamic_filename:
394394
if not disp.source_filename:
395-
raise CoverageException(
395+
raise PluginError(
396396
f"Plugin {plugin!r} didn't set source_filename for '{disp.original_filename}'"
397397
)
398398
reason = self.check_include_omit_etc(disp.source_filename, frame)

coverage/plugin_support.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import os.path
88
import sys
99

10-
from coverage.exceptions import CoverageException
10+
from coverage.exceptions import PluginError
1111
from coverage.misc import isolate_module
1212
from coverage.plugin import CoveragePlugin, FileTracer, FileReporter
1313

@@ -44,7 +44,7 @@ def load_plugins(cls, modules, config, debug=None):
4444

4545
coverage_init = getattr(mod, "coverage_init", None)
4646
if not coverage_init:
47-
raise CoverageException(
47+
raise PluginError(
4848
f"Plugin module {module!r} didn't define a coverage_init function"
4949
)
5050

coverage/report.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import sys
77

8-
from coverage.exceptions import CoverageException, NotPython
8+
from coverage.exceptions import CoverageException, NoDataError, NotPython
99
from coverage.files import prep_patterns, FnmatchMatcher
1010
from coverage.misc import ensure_dir_for_file, file_be_gone
1111

@@ -65,7 +65,7 @@ def get_analysis_to_report(coverage, morfs):
6565
file_reporters = [fr for fr in file_reporters if not matcher.match(fr.filename)]
6666

6767
if not file_reporters:
68-
raise CoverageException("No data to report.")
68+
raise NoDataError("No data to report.")
6969

7070
for fr in sorted(file_reporters):
7171
try:

coverage/results.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import collections
77

88
from coverage.debug import SimpleReprMixin
9-
from coverage.exceptions import CoverageException
9+
from coverage.exceptions import ConfigError
1010
from coverage.misc import contract, nice_pair
1111

1212

@@ -337,7 +337,7 @@ def should_fail_under(total, fail_under, precision):
337337
# We can never achieve higher than 100% coverage, or less than zero.
338338
if not (0 <= fail_under <= 100.0):
339339
msg = f"fail_under={fail_under} is invalid. Must be between 0 and 100."
340-
raise CoverageException(msg)
340+
raise ConfigError(msg)
341341

342342
# Special case for fail_under=100, it must really be 100.
343343
if fail_under == 100.0 and total != 100.0:

0 commit comments

Comments
 (0)