diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 919f3898..d131e9d0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,15 @@ Changelog ========= +Unreleased +---------- + +* Coverage's configuration file (and the data file) are now resolved relative to + pytest's rootdir instead of the current working directory, so running pytest + from a subdirectory no longer ignores the project's ``pyproject.toml`` / + ``setup.cfg`` / ``tox.ini`` / ``.coveragerc``. + See `#692 `_. + 7.1.0 (2026-03-21) ------------------ diff --git a/src/pytest_cov/engine.py b/src/pytest_cov/engine.py index a6a465fa..97541d94 100644 --- a/src/pytest_cov/engine.py +++ b/src/pytest_cov/engine.py @@ -52,8 +52,14 @@ def ensure_topdir_wrapper(self, *args, **kwargs): class CovController: """Base class for different plugin implementations.""" - def __init__(self, options: argparse.Namespace, config: Union[None, object], nodeid: Union[None, str]): - """Get some common config used by multiple derived classes.""" + def __init__( + self, + options: argparse.Namespace, + config: Union[None, object], + nodeid: Union[None, str], + rootdir: Union[None, str, Path] = None, + ): + """Get some common config used to get to the various derived classes.""" self.cov_source = options.cov_source self.cov_report = options.cov_report self.cov_config = options.cov_config @@ -68,7 +74,14 @@ def __init__(self, options: argparse.Namespace, config: Union[None, object], nod self.data_file = None self.node_descs = set() self.failed_workers = [] - self.topdir = os.fspath(Path.cwd()) + # Coverage resolves a relative ``config_file`` (the default + # ``.coveragerc``, and the ``pyproject.toml`` / ``setup.cfg`` / + # ``tox.ini`` fallbacks) and a relative ``data_file`` against the + # current working directory. pytest's rootdir is what the user means by + # "the project", so anchor to it when we know it -- otherwise running + # from a subdirectory silently ignores the project's coverage config + # (#692). It only differs from ``cwd`` in exactly that case. + self.topdir = os.fspath(rootdir) if rootdir is not None else os.fspath(Path.cwd()) self.is_collocated = None self.started = False diff --git a/src/pytest_cov/plugin.py b/src/pytest_cov/plugin.py index 9f5c3f76..d3f6a690 100644 --- a/src/pytest_cov/plugin.py +++ b/src/pytest_cov/plugin.py @@ -200,7 +200,11 @@ def pytest_load_initial_conftests(early_config, parser, args): break if early_config.known_args_namespace.cov_source: - plugin = CovPlugin(options, early_config.pluginmanager) + plugin = CovPlugin( + options, + early_config.pluginmanager, + rootdir=getattr(early_config, 'rootpath', None), + ) early_config.pluginmanager.register(plugin, '_cov') @@ -212,7 +216,14 @@ class CovPlugin: distributed worker. """ - def __init__(self, options: argparse.Namespace, pluginmanager, start=True, no_cov_should_warn=False): + def __init__( + self, + options: argparse.Namespace, + pluginmanager, + start=True, + no_cov_should_warn=False, + rootdir=None, + ): """Creates a coverage pytest plugin. We read the rc file that coverage uses to get the data file @@ -231,6 +242,10 @@ def __init__(self, options: argparse.Namespace, pluginmanager, start=True, no_co self._disabled = False self.options = options self._wrote_heading = False + # pytest's rootdir, used by the controllers to anchor coverage's + # config/data file lookup to the project root rather than the current + # working directory (#692). + self.rootdir = rootdir is_dist = getattr(options, 'numprocesses', False) or getattr(options, 'distload', False) or getattr(options, 'dist', 'no') != 'no' if getattr(options, 'no_cov', False): @@ -262,7 +277,7 @@ class Config: config = Config() - self.cov_controller = controller_cls(self.options, config, nodeid) + self.cov_controller = controller_cls(self.options, config, nodeid, self.rootdir) self.cov_controller.start() self._started = True self._start_path = Path.cwd() diff --git a/tests/test_pytest_cov.py b/tests/test_pytest_cov.py index b291f432..0b9dd539 100644 --- a/tests/test_pytest_cov.py +++ b/tests/test_pytest_cov.py @@ -1577,6 +1577,22 @@ def test_pyproject_toml(testdir): result.stdout.fnmatch_lines([f'test_pyproject_toml* {EXCLUDED_RESULT}']) +def test_config_at_rootdir_used_from_subdirectory(testdir, monkeypatch): + # The project's coverage config lives at the rootdir; running pytest from a + # subdirectory must still pick it up, instead of looking only in the cwd + # (#692). + testdir.makefile('.toml', pyproject=PYPROJECTTOML) + subdir = testdir.mkdir('subdir') + script = subdir.join('test_in_subdir.py') + script.write(EXCLUDED_TEST) + monkeypatch.chdir(subdir) + result = testdir.runpytest( + '-v', f'--cov={subdir}', '--cov-report=term-missing', 'test_in_subdir.py' + ) + assert result.ret == 0 + result.stdout.fnmatch_lines([f'*test_in_subdir* {EXCLUDED_RESULT}']) + + @pytest.mark.skipif('sys.platform == "win32" and platform.python_implementation() == "PyPy"') def test_coveragerc_dist(testdir): testdir.makefile('', coveragerc=COVERAGERC)