From cf70cba10d25ed7291b44fafab44bfad9142353f Mon Sep 17 00:00:00 2001 From: Jeremy Fleischman Date: Thu, 27 Mar 2025 16:31:26 -0500 Subject: [PATCH] feat: add new `source_dirs` option This completes https://github.com/nedbat/coveragepy/issues/1942#issuecomment-2759164456 --- CHANGES.rst | 7 +++++++ coverage/config.py | 2 ++ coverage/control.py | 8 ++++++++ coverage/inorout.py | 31 +++++++++++++++++++++---------- doc/config.rst | 12 ++++++++++++ tests/test_api.py | 18 +++++++++++++++++- tests/test_config.py | 2 ++ 7 files changed, 69 insertions(+), 11 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 0fdbfc28e..01b6b1434 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -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 diff --git a/coverage/config.py b/coverage/config.py index 75f314816..94831e070 100644 --- a/coverage/config.py +++ b/coverage/config.py @@ -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 @@ -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"), diff --git a/coverage/control.py b/coverage/control.py index d79c97ace..3547996ab 100644 --- a/coverage/control.py +++ b/coverage/control.py @@ -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, @@ -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. @@ -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. @@ -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, diff --git a/coverage/inorout.py b/coverage/inorout.py index 8814fb08d..2a10e0d37 100644 --- a/coverage/inorout.py +++ b/coverage/inorout.py @@ -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 @@ -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) @@ -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") @@ -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) @@ -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) @@ -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]]: diff --git a/doc/config.rst b/doc/config.rst index 87cbdd108..f62305dd1 100644 --- a/doc/config.rst +++ b/doc/config.rst @@ -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 diff --git a/tests/test_api.py b/tests/test_api.py index d85b89764..e4a042c13 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 @@ -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.""" diff --git a/tests/test_config.py b/tests/test_config.py index e0a975652..190a27b1c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -510,6 +510,7 @@ class ConfigFileTest(UsingModulesMixin, CoverageTest): omit = twenty source = myapp source_pkgs = ned + source_dirs = cooldir plugins = plugins.a_plugin plugins.another @@ -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"]