Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/109.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Propagate usage errors raised during worker collection, such as missing requested
paths, back to the controller so pytest reports the original error message and
exit code.
11 changes: 11 additions & 0 deletions src/xdist/dsession.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,17 @@ def worker_workerfinished(self, node: WorkerController) -> None:
workerready before shutdown was triggered.
"""
self.config.hook.pytest_testnodedown(node=node, error=None)
if node.workeroutput["exitstatus"] == int(pytest.ExitCode.USAGE_ERROR):
self._active_nodes.remove(node)
if not self.shuttingdown:
self.triggershutdown()

usage_error = node.workeroutput.get("usage_error")
if usage_error:
raise pytest.UsageError(*usage_error)

raise pytest.UsageError(f"{node} exited with a usage error")

if node.workeroutput["exitstatus"] == 2: # keyboard-interrupt
self.shouldstop = f"{node} received keyboard-interrupt"
self.worker_errordown(node, "keyboard-interrupt")
Expand Down
11 changes: 9 additions & 2 deletions src/xdist/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,16 @@ def pytest_sessionfinish(self, exitstatus: int) -> Generator[None, object, None]
yield
self.sendevent("workerfinished", workeroutput=workeroutput)

@pytest.hookimpl
def pytest_collection(self) -> None:
@pytest.hookimpl(hookwrapper=True)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use modern wrappers

def pytest_collection(self) -> Generator[None, object, None]:
self.sendevent("collectionstart")
outcome: Any = yield
if outcome.excinfo is not None and isinstance(
outcome.excinfo[1], pytest.UsageError
):
exc = outcome.excinfo[1]
workeroutput: dict[str, Any] = self.config.workeroutput # type: ignore[attr-defined]
workeroutput["usage_error"] = tuple(str(arg) for arg in exc.args)

def handle_command(
self, command: tuple[str, dict[str, Any]] | Literal[Marker.SHUTDOWN]
Expand Down
6 changes: 6 additions & 0 deletions testing/acceptance_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ def test_import():
result2 = pytester.runpytest(p1, "-n1")
assert len(result1.stdout.lines) == len(result2.stdout.lines)

def test_missing_path_usage_error(self, pytester: pytest.Pytester) -> None:
result = pytester.runpytest("-n2", "MISSING")

assert result.ret == pytest.ExitCode.USAGE_ERROR
result.stderr.fnmatch_lines(["ERROR: file or directory not found: MISSING"])

def test_n1_skip(self, pytester: pytest.Pytester) -> None:
p1 = pytester.makepyfile(
"""
Expand Down