Skip to content

feat: add new source_dirs option #1943

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,13 @@ Unreleased
.. _issue 1696: https://github.com/nedbat/coveragepy/issues/1696
.. _pull 1700: https://github.com/nedbat/coveragepy/pull/1700

- Added a new ``source_dirs`` setting for symmetry with the existing
``source_pkgs`` setting. It's preferable to the existing ``source`` setting,
because you'll get a clear error when directories don't exist. Fixes `issue
1942`_.

.. _issue 1942: https://github.com/nedbat/coveragepy/issues/1942


.. start-releases

Expand Down
2 changes: 2 additions & 0 deletions coverage/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ def __init__(self) -> None:
self.sigterm = False
self.source: list[str] | None = None
self.source_pkgs: list[str] = []
self.source_dirs: list[str] = []
self.timid = False
self._crash: str | None = None

Expand Down Expand Up @@ -392,6 +393,7 @@ def copy(self) -> CoverageConfig:
("sigterm", "run:sigterm", "boolean"),
("source", "run:source", "list"),
("source_pkgs", "run:source_pkgs", "list"),
("source_dirs", "run:source_dirs", "list"),
("timid", "run:timid", "boolean"),
("_crash", "run:_crash"),

Expand Down
8 changes: 8 additions & 0 deletions coverage/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ def __init__( # pylint: disable=too-many-arguments
config_file: FilePath | bool = True,
source: Iterable[str] | None = None,
source_pkgs: Iterable[str] | None = None,
source_dirs: Iterable[str] | None = None,
omit: str | Iterable[str] | None = None,
include: str | Iterable[str] | None = None,
debug: Iterable[str] | None = None,
Expand Down Expand Up @@ -188,6 +189,10 @@ def __init__( # pylint: disable=too-many-arguments
`source`, but can be used to name packages where the name can also be
interpreted as a file path.

`source_dirs` is a list of file paths. It works the same as
`source`, but raises an error if the path doesn't exist, rather
than being treated as a package name.

`include` and `omit` are lists of file name patterns. Files that match
`include` will be measured, files that match `omit` will not. Each
will also accept a single string argument.
Expand Down Expand Up @@ -235,6 +240,8 @@ def __init__( # pylint: disable=too-many-arguments
.. versionadded:: 7.7
The `plugins` parameter.

.. versionadded:: ???
The `source_dirs` parameter.
"""
# Start self.config as a usable default configuration. It will soon be
# replaced with the real configuration.
Expand Down Expand Up @@ -302,6 +309,7 @@ def __init__( # pylint: disable=too-many-arguments
parallel=bool_or_none(data_suffix),
source=source,
source_pkgs=source_pkgs,
source_dirs=source_dirs,
run_omit=omit,
run_include=include,
debug=debug,
Expand Down
31 changes: 21 additions & 10 deletions coverage/inorout.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@

from coverage import env
from coverage.disposition import FileDisposition, disposition_init
from coverage.exceptions import CoverageException, PluginError
from coverage.exceptions import ConfigError, CoverageException, PluginError
from coverage.files import TreeMatcher, GlobMatcher, ModuleMatcher
from coverage.files import prep_patterns, find_python_files, canonical_filename
from coverage.misc import isolate_module, sys_modules_saved
Expand Down Expand Up @@ -183,14 +183,25 @@ def __init__(
self.debug = debug
self.include_namespace_packages = include_namespace_packages

self.source: list[str] = []
self.source_pkgs: list[str] = []
self.source_pkgs.extend(config.source_pkgs)
self.source_dirs: list[str] = []
self.source_dirs.extend(config.source_dirs)
for src in config.source or []:
if os.path.isdir(src):
self.source.append(canonical_filename(src))
self.source_dirs.append(src)
else:
self.source_pkgs.append(src)

# Canonicalize everything in `source_dirs`.
# Also confirm that they actually are directories.
for i, src in enumerate(self.source_dirs):
self.source_dirs[i] = canonical_filename(src)

if not os.path.isdir(src):
raise ConfigError(f"Source dir doesn't exist, or is not a directory: {src}")


self.source_pkgs_unmatched = self.source_pkgs[:]

self.include = prep_patterns(config.run_include)
Expand Down Expand Up @@ -225,10 +236,10 @@ def _debug(msg: str) -> None:
self.pylib_match = None
self.include_match = self.omit_match = None

if self.source or self.source_pkgs:
if self.source_dirs or self.source_pkgs:
against = []
if self.source:
self.source_match = TreeMatcher(self.source, "source")
if self.source_dirs:
self.source_match = TreeMatcher(self.source_dirs, "source")
against.append(f"trees {self.source_match!r}")
if self.source_pkgs:
self.source_pkgs_match = ModuleMatcher(self.source_pkgs, "source_pkgs")
Expand Down Expand Up @@ -277,7 +288,7 @@ def _debug(msg: str) -> None:
)
self.source_in_third_paths.add(pathdir)

for src in self.source:
for src in self.source_dirs:
if self.third_match.match(src):
_debug(f"Source in third-party: source directory {src!r}")
self.source_in_third_paths.add(src)
Expand Down Expand Up @@ -449,12 +460,12 @@ def check_include_omit_etc(self, filename: str, frame: FrameType | None) -> str
def warn_conflicting_settings(self) -> None:
"""Warn if there are settings that conflict."""
if self.include:
if self.source or self.source_pkgs:
if self.source_dirs or self.source_pkgs:
self.warn("--include is ignored because --source is set", slug="include-ignored")

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

for src in self.source:
for src in self.source_dirs:
yield from self._find_executable_files(src)

def _find_plugin_files(self, src_dir: str) -> Iterable[tuple[str, str]]:
Expand Down
12 changes: 12 additions & 0 deletions doc/config.rst
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,18 @@ ambiguities between packages and directories.
.. versionadded:: 5.3


.. _config_run_source_dirs:

[run] source_dirs
.................

(multi-string) A list of directories, the source to measure during execution.
Operates the same as ``source``, but only names directories, for resolving
ambiguities between packages and directories.

.. versionadded:: ???


.. _config_run_timid:

[run] timid
Expand Down
18 changes: 17 additions & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
import coverage
from coverage import Coverage, env
from coverage.data import line_counts, sorted_lines
from coverage.exceptions import CoverageException, DataError, NoDataError, NoSource
from coverage.exceptions import ConfigError, CoverageException, DataError, NoDataError, NoSource
from coverage.files import abs_file, relative_filename
from coverage.misc import import_local_file
from coverage.types import FilePathClasses, FilePathType, TCovKwargs
Expand Down Expand Up @@ -963,6 +963,22 @@ def test_ambiguous_source_package_as_package(self) -> None:
# Because source= was specified, we do search for un-executed files.
assert lines['p1c'] == 0

def test_source_dirs(self) -> None:
os.chdir("tests_dir_modules")
assert os.path.isdir("pkg1")
lines = self.coverage_usepkgs_counts(source_dirs=["pkg1"])
self.filenames_in(list(lines), "p1a p1b")
self.filenames_not_in(list(lines), "p2a p2b othera otherb osa osb")
# Because source_dirs= was specified, we do search for un-executed files.
assert lines['p1c'] == 0

def test_non_existent_source_dir(self) -> None:
with pytest.raises(
ConfigError,
match=re.escape("Source dir doesn't exist, or is not a directory: i-do-not-exist"),
):
self.coverage_usepkgs_counts(source_dirs=["i-do-not-exist"])


class ReportIncludeOmitTest(IncludeOmitTestsMixin, CoverageTest):
"""Tests of the report include/omit functionality."""
Expand Down
2 changes: 2 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ class ConfigFileTest(UsingModulesMixin, CoverageTest):
omit = twenty
source = myapp
source_pkgs = ned
source_dirs = cooldir
plugins =
plugins.a_plugin
plugins.another
Expand Down Expand Up @@ -604,6 +605,7 @@ def assert_config_settings_are_correct(self, cov: Coverage) -> None:
assert cov.config.concurrency == ["thread"]
assert cov.config.source == ["myapp"]
assert cov.config.source_pkgs == ["ned"]
assert cov.config.source_dirs == ["cooldir"]
assert cov.config.disable_warnings == ["abcd", "efgh"]

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