From 2468904c8f4af5b9ef71c6ffe8c19c9f9c7ccb33 Mon Sep 17 00:00:00 2001 From: Mikhail Golikov Date: Sun, 14 Jun 2026 12:06:44 +0100 Subject: [PATCH 1/2] Fix CLI options ignored when --looponfail runs with -n When --looponfail runs the failing tests through distributed (-n) workers, the worker session config was rebuilt only from the parsed options and the positional arguments, so config.invocation_params.args lost the original command-line flags such as --tb=short. xdist reads invocation_params.args to replicate options on its workers, so the nested workers fell back to their defaults (for example, the long traceback style). Restore the full invocation arguments in the looponfail worker config so those options propagate to the nested workers. Closes #767 --- changelog/767.bugfix.rst | 5 +++++ src/xdist/looponfail.py | 11 ++++++++++ testing/test_looponfail.py | 44 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 changelog/767.bugfix.rst diff --git a/changelog/767.bugfix.rst b/changelog/767.bugfix.rst new file mode 100644 index 00000000..ab50fa98 --- /dev/null +++ b/changelog/767.bugfix.rst @@ -0,0 +1,5 @@ +Command-line options such as ``--tb=short`` are no longer dropped when +``--looponfail`` is combined with distributed runs (``-n``). Previously the +worker session was rebuilt only from the parsed options and positional +arguments, so the original invocation flags were lost and the nested workers +fell back to their defaults (for example, the long traceback style). diff --git a/src/xdist/looponfail.py b/src/xdist/looponfail.py index 7fcebe51..89fdd5eb 100644 --- a/src/xdist/looponfail.py +++ b/src/xdist/looponfail.py @@ -93,6 +93,7 @@ def setup(self) -> None: init_worker_session, args=self.config.args, option_dict=vars(self.config.option), + invocation_args=list(self.config.invocation_params.args), ) remote_outchannel: execnet.Channel = channel.receive() @@ -161,7 +162,9 @@ def init_worker_session( channel: "execnet.Channel", # noqa: UP037 args: list[str], option_dict: dict[str, "Any"], # noqa: UP037 + invocation_args: list[str], ) -> None: + import dataclasses import os import sys @@ -183,6 +186,14 @@ def init_worker_session( config = Config.fromdictargs(option_dict, list(args)) config.args = args + # fromdictargs() rebuilds the config from the parsed options plus the + # positional arguments only, so config.invocation_params.args loses the + # original command-line flags (e.g. --tb=short). Restore the full + # invocation arguments so that any nested distributed (-n) run propagates + # those options to its workers. See #767. + config.invocation_params = dataclasses.replace( + config.invocation_params, args=tuple(invocation_args) + ) from xdist.looponfail import WorkerFailSession WorkerFailSession(config, channel).main() diff --git a/testing/test_looponfail.py b/testing/test_looponfail.py index 844736f6..3242d3e6 100644 --- a/testing/test_looponfail.py +++ b/testing/test_looponfail.py @@ -342,6 +342,50 @@ def runsession_dups() -> tuple[list[str], list[str], bool]: remotecontrol.loop_once() assert len(remotecontrol.failures) == 1 + def test_looponfail_passes_command_line_options_to_workers( + self, + pytester: pytest.Pytester, + capsys: pytest.CaptureFixture[str], + ) -> None: + # Regression test for #767: command-line options such as --tb=short + # were dropped when looponfail ran the failing tests through + # distributed (-n) workers. The reconstructed worker config lost the + # original invocation arguments, so the nested workers fell back to + # the default (long) traceback style. + pytester.makepyfile( + foo=textwrap.dedent( + """ + def a(): + b() + + def b(): + c() + + def c(): + assert False + """ + ) + ) + modcol = pytester.getmodulecol( + textwrap.dedent( + """ + from foo import a + + def test_foo(): + a() + """ + ), + configargs=["-n2", "--tb=short"], + ) + control = RemoteControl(modcol.config) + control.loop_once() + out, _err = capsys.readouterr() + # --tb=short renders frames as compact "path:lineno: in func" lines. + # The default (long) style instead expands the source ("def test_foo():" + # with a ">" marker), so its absence confirms the option propagated. + assert ": in test_foo" in out + assert "def test_foo():" not in out + class TestFunctional: def test_fail_to_ok(self, pytester: pytest.Pytester) -> None: From bc8b24c78d78f1a3989c940583e32beebda3f498 Mon Sep 17 00:00:00 2001 From: Mikhail Golikov Date: Sun, 14 Jun 2026 12:18:13 +0100 Subject: [PATCH 2/2] Construct InvocationParams directly for pytest 7.x compatibility dataclasses.replace() raises TypeError on pytest 7.x, where InvocationParams is a frozen attrs class rather than a dataclass. Build the replacement through the type's own constructor instead, which works for both the attrs (7.x) and dataclass (newer pytest) variants. --- src/xdist/looponfail.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/xdist/looponfail.py b/src/xdist/looponfail.py index 89fdd5eb..0f9c5b2f 100644 --- a/src/xdist/looponfail.py +++ b/src/xdist/looponfail.py @@ -164,7 +164,6 @@ def init_worker_session( option_dict: dict[str, "Any"], # noqa: UP037 invocation_args: list[str], ) -> None: - import dataclasses import os import sys @@ -191,8 +190,16 @@ def init_worker_session( # original command-line flags (e.g. --tb=short). Restore the full # invocation arguments so that any nested distributed (-n) run propagates # those options to its workers. See #767. - config.invocation_params = dataclasses.replace( - config.invocation_params, args=tuple(invocation_args) + # + # InvocationParams is constructed directly (rather than via + # dataclasses.replace) because it is a frozen attrs class on pytest 7.x + # and a dataclass only on newer pytest; calling its type with the same + # fields works across both. + invocation_params = config.invocation_params + config.invocation_params = type(invocation_params)( + args=tuple(invocation_args), + plugins=invocation_params.plugins, + dir=invocation_params.dir, ) from xdist.looponfail import WorkerFailSession