diff --git a/AUTHORS.rst b/AUTHORS.rst index 561367d7..31f24280 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -70,3 +70,4 @@ Authors * Markéta Machová - https://github.com/MeggyCal * xuu33030 - https://github.com/xuu33030 * dly - https://github.com/xlyy1 +* pctablet505 - https://github.com/pctablet505 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cf403789..4414eb18 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,11 @@ Unreleased from a subdirectory no longer ignores the project's ``pyproject.toml`` / ``setup.cfg`` / ``tox.ini`` / ``.coveragerc``. See `#692 `_. +* Fixed a confusing ``AttributeError: 'Central' object has no attribute 'configure_node'`` crash + that could happen when another plugin (for example one driven by ``addopts`` in a config file) + enables pytest-xdist only after pytest-cov has already decided to run in centralised mode. + This now raises a clear ``DistCovError`` instead. + See `#740 `_. 7.1.0 (2026-03-21) ------------------ diff --git a/src/pytest_cov/plugin.py b/src/pytest_cov/plugin.py index 9f23a890..550e49d9 100644 --- a/src/pytest_cov/plugin.py +++ b/src/pytest_cov/plugin.py @@ -12,6 +12,7 @@ from . import CovDisabledWarning from . import CovReportWarning +from . import DistCovError from . import PytestCovWarning if TYPE_CHECKING: @@ -327,6 +328,7 @@ def pytest_configure_node(self, node): Mark this hook as optional in case xdist is not installed. """ if not self._disabled: + self._check_is_distributed() self.cov_controller.configure_node(node) @pytest.hookimpl(optionalhook=True) @@ -336,8 +338,36 @@ def pytest_testnodedown(self, node, error): Mark this hook as optional in case xdist is not installed. """ if not self._disabled: + self._check_is_distributed() self.cov_controller.testnodedown(node, error) + def _check_is_distributed(self): + """Guard against xdist hooks firing on a non-distributed controller. + + Whether we run centralised or distributed is decided very early on, in + ``pytest_load_initial_conftests``, based on the xdist options known at that + point. If some other plugin (e.g. one that enables xdist based on + ``pyproject.toml``/``addopts``) only adds ``-n``/``--dist`` afterwards, we can + end up having already started a centralised controller by the time xdist + calls one of its distributed-only hooks. Recovering from that would mean + discarding coverage already collected, so we fail with a clear error instead + of a confusing AttributeError. + """ + # import engine lazily here to avoid importing + # it for unit tests that don't need it + from . import engine + + if not isinstance(self.cov_controller, engine.DistMaster): + raise DistCovError( + 'pytest-xdist workers were started but pytest-cov had already initialized itself for ' + 'centralised (non-distributed) coverage measurement. This usually happens when another ' + 'plugin enables pytest-xdist (e.g. via "addopts" coming from a config file or a plugin ' + 'like pytest-enabler) after pytest-cov has already inspected the command line. ' + 'Make sure the xdist options (-n/--dist) are visible on the command line or in the pytest ' + 'configuration file before pytest-cov starts, for example by adding them to the "addopts" ' + 'pytest reads directly, rather than relying on a plugin that injects them later.' + ) + def _should_report(self): needed = self.options.cov_report or self.options.cov_fail_under return needed and not (self.failed and self.options.no_cov_on_fail) diff --git a/tests/test_pytest_cov.py b/tests/test_pytest_cov.py index 098dc3fd..693ddcb4 100644 --- a/tests/test_pytest_cov.py +++ b/tests/test_pytest_cov.py @@ -1906,6 +1906,29 @@ def test_dynamic_context(pytester, testdir, opts, prop): ) +def test_xdist_enabled_after_initial_conftests(pytester, testdir): + """A plugin that only enables xdist after pytest-cov looked at the command + line (e.g. one that reacts to "addopts" from a config file) used to crash + with a cryptic ``AttributeError: 'Central' object has no attribute + 'configure_node'``. See #740. + """ + testdir.makepyfile(test_1='def test_1():\n assert True\n') + testdir.makepyfile( + late_xdist_plugin=""" + def pytest_load_initial_conftests(early_config, parser, args): + # Simulate a plugin that decides to enable xdist only after + # pytest-cov's own `tryfirst=True` pytest_load_initial_conftests + # has already run and started coverage measurement centrally. + args.extend(['-n', '2']) + """ + ) + testdir.syspathinsert() + result = testdir.runpytest('-v', '--cov=.', '--cov-report=', '-p', 'late_xdist_plugin') + output = '\n'.join(result.outlines + result.errlines) + assert 'pytest_cov.DistCovError: pytest-xdist workers were started' in output + assert 'AttributeError' not in output + + @xdist_params def test_simple(pytester, testdir, opts, prop): script = testdir.makepyfile(test_1=prop.code)