diff --git a/AUTHORS.rst b/AUTHORS.rst index 3291067a..328790dd 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -67,3 +67,4 @@ Authors * Ofek Lev - https://github.com/ofek * Art Pelling - https://github.com/artpelling * Markéta Machová - https://github.com/MeggyCal +* pctablet505 - https://github.com/pctablet505 diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 15a028dd..e46e6209 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,15 @@ Changelog ========= +7.1.1 (unreleased) +------------------ + +* 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 9f5c3f76..d4525039 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: @@ -304,6 +305,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) @@ -313,8 +315,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 b291f432..e33cd2cf 100644 --- a/tests/test_pytest_cov.py +++ b/tests/test_pytest_cov.py @@ -1833,6 +1833,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)