Skip to content

Commit 7aea2f3

Browse files
authored
feat: add new source_dirs option (#1943)
This completes #1942 (comment)
1 parent f464155 commit 7aea2f3

File tree

7 files changed

+69
-11
lines changed

7 files changed

+69
-11
lines changed

CHANGES.rst

+7
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ Unreleased
3232
.. _issue 1696: https://github.com/nedbat/coveragepy/issues/1696
3333
.. _pull 1700: https://github.com/nedbat/coveragepy/pull/1700
3434

35+
- Added a new ``source_dirs`` setting for symmetry with the existing
36+
``source_pkgs`` setting. It's preferable to the existing ``source`` setting,
37+
because you'll get a clear error when directories don't exist. Fixes `issue
38+
1942`_.
39+
40+
.. _issue 1942: https://github.com/nedbat/coveragepy/issues/1942
41+
3542

3643
.. start-releases
3744

coverage/config.py

+2
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ def __init__(self) -> None:
211211
self.sigterm = False
212212
self.source: list[str] | None = None
213213
self.source_pkgs: list[str] = []
214+
self.source_dirs: list[str] = []
214215
self.timid = False
215216
self._crash: str | None = None
216217

@@ -392,6 +393,7 @@ def copy(self) -> CoverageConfig:
392393
("sigterm", "run:sigterm", "boolean"),
393394
("source", "run:source", "list"),
394395
("source_pkgs", "run:source_pkgs", "list"),
396+
("source_dirs", "run:source_dirs", "list"),
395397
("timid", "run:timid", "boolean"),
396398
("_crash", "run:_crash"),
397399

coverage/control.py

+8
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ def __init__( # pylint: disable=too-many-arguments
131131
config_file: FilePath | bool = True,
132132
source: Iterable[str] | None = None,
133133
source_pkgs: Iterable[str] | None = None,
134+
source_dirs: Iterable[str] | None = None,
134135
omit: str | Iterable[str] | None = None,
135136
include: str | Iterable[str] | None = None,
136137
debug: Iterable[str] | None = None,
@@ -188,6 +189,10 @@ def __init__( # pylint: disable=too-many-arguments
188189
`source`, but can be used to name packages where the name can also be
189190
interpreted as a file path.
190191
192+
`source_dirs` is a list of file paths. It works the same as
193+
`source`, but raises an error if the path doesn't exist, rather
194+
than being treated as a package name.
195+
191196
`include` and `omit` are lists of file name patterns. Files that match
192197
`include` will be measured, files that match `omit` will not. Each
193198
will also accept a single string argument.
@@ -235,6 +240,8 @@ def __init__( # pylint: disable=too-many-arguments
235240
.. versionadded:: 7.7
236241
The `plugins` parameter.
237242
243+
.. versionadded:: ???
244+
The `source_dirs` parameter.
238245
"""
239246
# Start self.config as a usable default configuration. It will soon be
240247
# replaced with the real configuration.
@@ -302,6 +309,7 @@ def __init__( # pylint: disable=too-many-arguments
302309
parallel=bool_or_none(data_suffix),
303310
source=source,
304311
source_pkgs=source_pkgs,
312+
source_dirs=source_dirs,
305313
run_omit=omit,
306314
run_include=include,
307315
debug=debug,

coverage/inorout.py

+21-10
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
from coverage import env
2626
from coverage.disposition import FileDisposition, disposition_init
27-
from coverage.exceptions import CoverageException, PluginError
27+
from coverage.exceptions import ConfigError, CoverageException, PluginError
2828
from coverage.files import TreeMatcher, GlobMatcher, ModuleMatcher
2929
from coverage.files import prep_patterns, find_python_files, canonical_filename
3030
from coverage.misc import isolate_module, sys_modules_saved
@@ -183,14 +183,25 @@ def __init__(
183183
self.debug = debug
184184
self.include_namespace_packages = include_namespace_packages
185185

186-
self.source: list[str] = []
187186
self.source_pkgs: list[str] = []
188187
self.source_pkgs.extend(config.source_pkgs)
188+
self.source_dirs: list[str] = []
189+
self.source_dirs.extend(config.source_dirs)
189190
for src in config.source or []:
190191
if os.path.isdir(src):
191-
self.source.append(canonical_filename(src))
192+
self.source_dirs.append(src)
192193
else:
193194
self.source_pkgs.append(src)
195+
196+
# Canonicalize everything in `source_dirs`.
197+
# Also confirm that they actually are directories.
198+
for i, src in enumerate(self.source_dirs):
199+
self.source_dirs[i] = canonical_filename(src)
200+
201+
if not os.path.isdir(src):
202+
raise ConfigError(f"Source dir doesn't exist, or is not a directory: {src}")
203+
204+
194205
self.source_pkgs_unmatched = self.source_pkgs[:]
195206

196207
self.include = prep_patterns(config.run_include)
@@ -225,10 +236,10 @@ def _debug(msg: str) -> None:
225236
self.pylib_match = None
226237
self.include_match = self.omit_match = None
227238

228-
if self.source or self.source_pkgs:
239+
if self.source_dirs or self.source_pkgs:
229240
against = []
230-
if self.source:
231-
self.source_match = TreeMatcher(self.source, "source")
241+
if self.source_dirs:
242+
self.source_match = TreeMatcher(self.source_dirs, "source")
232243
against.append(f"trees {self.source_match!r}")
233244
if self.source_pkgs:
234245
self.source_pkgs_match = ModuleMatcher(self.source_pkgs, "source_pkgs")
@@ -277,7 +288,7 @@ def _debug(msg: str) -> None:
277288
)
278289
self.source_in_third_paths.add(pathdir)
279290

280-
for src in self.source:
291+
for src in self.source_dirs:
281292
if self.third_match.match(src):
282293
_debug(f"Source in third-party: source directory {src!r}")
283294
self.source_in_third_paths.add(src)
@@ -449,12 +460,12 @@ def check_include_omit_etc(self, filename: str, frame: FrameType | None) -> str
449460
def warn_conflicting_settings(self) -> None:
450461
"""Warn if there are settings that conflict."""
451462
if self.include:
452-
if self.source or self.source_pkgs:
463+
if self.source_dirs or self.source_pkgs:
453464
self.warn("--include is ignored because --source is set", slug="include-ignored")
454465

455466
def warn_already_imported_files(self) -> None:
456467
"""Warn if files have already been imported that we will be measuring."""
457-
if self.include or self.source or self.source_pkgs:
468+
if self.include or self.source_dirs or self.source_pkgs:
458469
warned = set()
459470
for mod in list(sys.modules.values()):
460471
filename = getattr(mod, "__file__", None)
@@ -527,7 +538,7 @@ def find_possibly_unexecuted_files(self) -> Iterable[tuple[str, str | None]]:
527538
pkg_file = source_for_file(cast(str, sys.modules[pkg].__file__))
528539
yield from self._find_executable_files(canonical_path(pkg_file))
529540

530-
for src in self.source:
541+
for src in self.source_dirs:
531542
yield from self._find_executable_files(src)
532543

533544
def _find_plugin_files(self, src_dir: str) -> Iterable[tuple[str, str]]:

doc/config.rst

+12
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,18 @@ ambiguities between packages and directories.
476476
.. versionadded:: 5.3
477477

478478

479+
.. _config_run_source_dirs:
480+
481+
[run] source_dirs
482+
.................
483+
484+
(multi-string) A list of directories, the source to measure during execution.
485+
Operates the same as ``source``, but only names directories, for resolving
486+
ambiguities between packages and directories.
487+
488+
.. versionadded:: ???
489+
490+
479491
.. _config_run_timid:
480492

481493
[run] timid

tests/test_api.py

+17-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import coverage
2424
from coverage import Coverage, env
2525
from coverage.data import line_counts, sorted_lines
26-
from coverage.exceptions import CoverageException, DataError, NoDataError, NoSource
26+
from coverage.exceptions import ConfigError, CoverageException, DataError, NoDataError, NoSource
2727
from coverage.files import abs_file, relative_filename
2828
from coverage.misc import import_local_file
2929
from coverage.types import FilePathClasses, FilePathType, TCovKwargs
@@ -963,6 +963,22 @@ def test_ambiguous_source_package_as_package(self) -> None:
963963
# Because source= was specified, we do search for un-executed files.
964964
assert lines['p1c'] == 0
965965

966+
def test_source_dirs(self) -> None:
967+
os.chdir("tests_dir_modules")
968+
assert os.path.isdir("pkg1")
969+
lines = self.coverage_usepkgs_counts(source_dirs=["pkg1"])
970+
self.filenames_in(list(lines), "p1a p1b")
971+
self.filenames_not_in(list(lines), "p2a p2b othera otherb osa osb")
972+
# Because source_dirs= was specified, we do search for un-executed files.
973+
assert lines['p1c'] == 0
974+
975+
def test_non_existent_source_dir(self) -> None:
976+
with pytest.raises(
977+
ConfigError,
978+
match=re.escape("Source dir doesn't exist, or is not a directory: i-do-not-exist"),
979+
):
980+
self.coverage_usepkgs_counts(source_dirs=["i-do-not-exist"])
981+
966982

967983
class ReportIncludeOmitTest(IncludeOmitTestsMixin, CoverageTest):
968984
"""Tests of the report include/omit functionality."""

tests/test_config.py

+2
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ class ConfigFileTest(UsingModulesMixin, CoverageTest):
510510
omit = twenty
511511
source = myapp
512512
source_pkgs = ned
513+
source_dirs = cooldir
513514
plugins =
514515
plugins.a_plugin
515516
plugins.another
@@ -604,6 +605,7 @@ def assert_config_settings_are_correct(self, cov: Coverage) -> None:
604605
assert cov.config.concurrency == ["thread"]
605606
assert cov.config.source == ["myapp"]
606607
assert cov.config.source_pkgs == ["ned"]
608+
assert cov.config.source_dirs == ["cooldir"]
607609
assert cov.config.disable_warnings == ["abcd", "efgh"]
608610

609611
assert cov.get_exclude_list() == ["if 0:", r"pragma:?\s+no cover", "another_tab"]

0 commit comments

Comments
 (0)