@@ -5,11 +5,15 @@ from enum import Enum
|
||||
from queue import Empty
|
||||
from queue import Queue
|
||||
import sys
|
||||
from typing import Any
|
||||
from typing import Sequence
|
||||
import warnings
|
||||
|
||||
import execnet
|
||||
import pytest
|
||||
|
||||
from xdist.remote import Producer
|
||||
from xdist.remote import WorkerInfo
|
||||
from xdist.scheduler import EachScheduling
|
||||
from xdist.scheduler import LoadFileScheduling
|
||||
from xdist.scheduler import LoadGroupScheduling
|
||||
@@ -18,6 +22,7 @@ from xdist.scheduler import LoadScopeScheduling
|
||||
from xdist.scheduler import Scheduling
|
||||
from xdist.scheduler import WorkStealingScheduling
|
||||
from xdist.workermanage import NodeManager
|
||||
from xdist.workermanage import WorkerController
|
||||
|
||||
|
||||
class Interrupted(KeyboardInterrupt):
|
||||
@@ -38,29 +43,31 @@ class DSession:
|
||||
it will wait for instructions.
|
||||
"""
|
||||
|
||||
def __init__(self, config):
|
||||
shouldstop: bool | str
|
||||
|
||||
def __init__(self, config: pytest.Config) -> None:
|
||||
self.config = config
|
||||
self.log = Producer("dsession", enabled=config.option.debug)
|
||||
self.nodemanager = None
|
||||
self.sched = None
|
||||
self.nodemanager: NodeManager | None = None
|
||||
self.sched: Scheduling | None = None
|
||||
self.shuttingdown = False
|
||||
self.countfailures = 0
|
||||
self.maxfail = config.getvalue("maxfail")
|
||||
self.queue = Queue()
|
||||
self._session = None
|
||||
self._failed_collection_errors = {}
|
||||
self._active_nodes = set()
|
||||
self.maxfail: int = config.getvalue("maxfail")
|
||||
self.queue: Queue[tuple[str, dict[str, Any]]] = Queue()
|
||||
self._session: pytest.Session | None = None
|
||||
self._failed_collection_errors: dict[object, bool] = {}
|
||||
self._active_nodes: set[WorkerController] = set()
|
||||
self._failed_nodes_count = 0
|
||||
self._max_worker_restart = get_default_max_worker_restart(self.config)
|
||||
# summary message to print at the end of the session
|
||||
self._summary_report = None
|
||||
self._summary_report: str | None = None
|
||||
self.terminal = config.pluginmanager.getplugin("terminalreporter")
|
||||
if self.terminal:
|
||||
self.trdist = TerminalDistReporter(config)
|
||||
config.pluginmanager.register(self.trdist, "terminaldistreporter")
|
||||
|
||||
@property
|
||||
def session_finished(self):
|
||||
def session_finished(self) -> bool:
|
||||
"""Return True if the distributed session has finished.
|
||||
|
||||
This means all nodes have executed all test items. This is
|
||||
@@ -68,12 +75,12 @@ class DSession:
|
||||
"""
|
||||
return bool(self.shuttingdown and not self._active_nodes)
|
||||
|
||||
def report_line(self, line):
|
||||
def report_line(self, line: str) -> None:
|
||||
if self.terminal and self.config.option.verbose >= 0:
|
||||
self.terminal.write_line(line)
|
||||
|
||||
@pytest.hookimpl(trylast=True)
|
||||
def pytest_sessionstart(self, session):
|
||||
def pytest_sessionstart(self, session: pytest.Session) -> None:
|
||||
"""Creates and starts the nodes.
|
||||
|
||||
The nodes are setup to put their events onto self.queue. As
|
||||
@@ -85,7 +92,7 @@ class DSession:
|
||||
self._session = session
|
||||
|
||||
@pytest.hookimpl
|
||||
def pytest_sessionfinish(self, session):
|
||||
def pytest_sessionfinish(self) -> None:
|
||||
"""Shutdown all nodes."""
|
||||
nm = getattr(self, "nodemanager", None) # if not fully initialized
|
||||
if nm is not None:
|
||||
@@ -93,12 +100,16 @@ class DSession:
|
||||
self._session = None
|
||||
|
||||
@pytest.hookimpl
|
||||
def pytest_collection(self):
|
||||
def pytest_collection(self) -> bool:
|
||||
# prohibit collection of test items in controller process
|
||||
return True
|
||||
|
||||
@pytest.hookimpl(trylast=True)
|
||||
def pytest_xdist_make_scheduler(self, config, log) -> Scheduling | None:
|
||||
def pytest_xdist_make_scheduler(
|
||||
self,
|
||||
config: pytest.Config,
|
||||
log: Producer,
|
||||
) -> Scheduling | None:
|
||||
dist = config.getvalue("dist")
|
||||
if dist == "each":
|
||||
return EachScheduling(config, log)
|
||||
@@ -115,7 +126,7 @@ class DSession:
|
||||
return None
|
||||
|
||||
@pytest.hookimpl
|
||||
def pytest_runtestloop(self):
|
||||
def pytest_runtestloop(self) -> bool:
|
||||
self.sched = self.config.hook.pytest_xdist_make_scheduler(
|
||||
config=self.config, log=self.log
|
||||
)
|
||||
@@ -132,7 +143,7 @@ class DSession:
|
||||
raise pending_exception
|
||||
return True
|
||||
|
||||
def loop_once(self):
|
||||
def loop_once(self) -> None:
|
||||
"""Process one callback from one of the workers."""
|
||||
while 1:
|
||||
if not self._active_nodes:
|
||||
@@ -150,6 +161,7 @@ class DSession:
|
||||
call = getattr(self, method)
|
||||
self.log("calling method", method, kwargs)
|
||||
call(**kwargs)
|
||||
assert self.sched is not None
|
||||
if self.sched.tests_finished:
|
||||
self.triggershutdown()
|
||||
|
||||
@@ -157,7 +169,11 @@ class DSession:
|
||||
# callbacks for processing events from workers
|
||||
#
|
||||
|
||||
def worker_workerready(self, node, workerinfo):
|
||||
def worker_workerready(
|
||||
self,
|
||||
node: WorkerController,
|
||||
workerinfo: WorkerInfo,
|
||||
) -> None:
|
||||
"""Emitted when a node first starts up.
|
||||
|
||||
This adds the node to the scheduler, nodes continue with
|
||||
@@ -171,9 +187,10 @@ class DSession:
|
||||
if self.shuttingdown:
|
||||
node.shutdown()
|
||||
else:
|
||||
assert self.sched is not None
|
||||
self.sched.add_node(node)
|
||||
|
||||
def worker_workerfinished(self, node):
|
||||
def worker_workerfinished(self, node: WorkerController) -> None:
|
||||
"""Emitted when node executes its pytest_sessionfinish hook.
|
||||
|
||||
Removes the node from the scheduler.
|
||||
@@ -194,12 +211,15 @@ class DSession:
|
||||
self.shouldstop = shouldx
|
||||
break
|
||||
else:
|
||||
assert self.sched is not None
|
||||
if node in self.sched.nodes:
|
||||
crashitem = self.sched.remove_node(node)
|
||||
assert not crashitem, (crashitem, node)
|
||||
self._active_nodes.remove(node)
|
||||
|
||||
def worker_internal_error(self, node, formatted_error):
|
||||
def worker_internal_error(
|
||||
self, node: WorkerController, formatted_error: str
|
||||
) -> None:
|
||||
"""
|
||||
pytest_internalerror() was called on the worker.
|
||||
|
||||
@@ -215,9 +235,10 @@ class DSession:
|
||||
excrepr = excinfo.getrepr()
|
||||
self.config.hook.pytest_internalerror(excrepr=excrepr, excinfo=excinfo)
|
||||
|
||||
def worker_errordown(self, node, error):
|
||||
def worker_errordown(self, node: WorkerController, error: object | None) -> None:
|
||||
"""Emitted by the WorkerController when a node dies."""
|
||||
self.config.hook.pytest_testnodedown(node=node, error=error)
|
||||
assert self.sched is not None
|
||||
try:
|
||||
crashitem = self.sched.remove_node(node)
|
||||
except KeyError:
|
||||
@@ -235,7 +256,7 @@ class DSession:
|
||||
if self._max_worker_restart == 0:
|
||||
msg = f"worker {node.gateway.id} crashed and worker restarting disabled"
|
||||
else:
|
||||
msg = "maximum crashed workers reached: %d" % self._max_worker_restart
|
||||
msg = f"maximum crashed workers reached: {self._max_worker_restart}"
|
||||
self._summary_report = msg
|
||||
self.report_line("\n" + msg)
|
||||
self.triggershutdown()
|
||||
@@ -246,11 +267,13 @@ class DSession:
|
||||
self._active_nodes.remove(node)
|
||||
|
||||
@pytest.hookimpl
|
||||
def pytest_terminal_summary(self, terminalreporter):
|
||||
def pytest_terminal_summary(self, terminalreporter: Any) -> None:
|
||||
if self.config.option.verbose >= 0 and self._summary_report:
|
||||
terminalreporter.write_sep("=", f"xdist: {self._summary_report}")
|
||||
|
||||
def worker_collectionfinish(self, node, ids):
|
||||
def worker_collectionfinish(
|
||||
self, node: WorkerController, ids: Sequence[str]
|
||||
) -> None:
|
||||
"""Worker has finished test collection.
|
||||
|
||||
This adds the collection for this node to the scheduler. If
|
||||
@@ -264,7 +287,9 @@ class DSession:
|
||||
self.config.hook.pytest_xdist_node_collection_finished(node=node, ids=ids)
|
||||
# tell session which items were effectively collected otherwise
|
||||
# the controller node will finish the session with EXIT_NOTESTSCOLLECTED
|
||||
assert self._session is not None
|
||||
self._session.testscollected = len(ids)
|
||||
assert self.sched is not None
|
||||
self.sched.add_node_collection(node, ids)
|
||||
if self.terminal:
|
||||
self.trdist.setstatus(
|
||||
@@ -280,29 +305,44 @@ class DSession:
|
||||
)
|
||||
self.sched.schedule()
|
||||
|
||||
def worker_logstart(self, node, nodeid, location):
|
||||
def worker_logstart(
|
||||
self,
|
||||
node: WorkerController,
|
||||
nodeid: str,
|
||||
location: tuple[str, int | None, str],
|
||||
) -> None:
|
||||
"""Emitted when a node calls the pytest_runtest_logstart hook."""
|
||||
self.config.hook.pytest_runtest_logstart(nodeid=nodeid, location=location)
|
||||
|
||||
def worker_logfinish(self, node, nodeid, location):
|
||||
def worker_logfinish(
|
||||
self,
|
||||
node: WorkerController,
|
||||
nodeid: str,
|
||||
location: tuple[str, int | None, str],
|
||||
) -> None:
|
||||
"""Emitted when a node calls the pytest_runtest_logfinish hook."""
|
||||
self.config.hook.pytest_runtest_logfinish(nodeid=nodeid, location=location)
|
||||
|
||||
def worker_testreport(self, node, rep):
|
||||
def worker_testreport(self, node: WorkerController, rep: pytest.TestReport) -> None:
|
||||
"""Emitted when a node calls the pytest_runtest_logreport hook."""
|
||||
rep.node = node
|
||||
rep.node = node # type: ignore[attr-defined]
|
||||
self.config.hook.pytest_runtest_logreport(report=rep)
|
||||
self._handlefailures(rep)
|
||||
|
||||
def worker_runtest_protocol_complete(self, node, item_index, duration):
|
||||
def worker_runtest_protocol_complete(
|
||||
self, node: WorkerController, item_index: int, duration: float
|
||||
) -> None:
|
||||
"""
|
||||
Emitted when a node fires the 'runtest_protocol_complete' event,
|
||||
signalling that a test has completed the runtestprotocol and should be
|
||||
removed from the pending list in the scheduler.
|
||||
"""
|
||||
assert self.sched is not None
|
||||
self.sched.mark_test_complete(node, item_index, duration)
|
||||
|
||||
def worker_unscheduled(self, node, indices):
|
||||
def worker_unscheduled(
|
||||
self, node: WorkerController, indices: Sequence[int]
|
||||
) -> None:
|
||||
"""
|
||||
Emitted when a node fires the 'unscheduled' event, signalling that
|
||||
some tests have been removed from the worker's queue and should be
|
||||
@@ -311,9 +351,14 @@ class DSession:
|
||||
This should happen only in response to 'steal' command, so schedulers
|
||||
not using 'steal' command don't have to implement it.
|
||||
"""
|
||||
assert self.sched is not None
|
||||
self.sched.remove_pending_tests_from_node(node, indices)
|
||||
|
||||
def worker_collectreport(self, node, rep):
|
||||
def worker_collectreport(
|
||||
self,
|
||||
node: WorkerController,
|
||||
rep: pytest.CollectReport | pytest.TestReport,
|
||||
) -> None:
|
||||
"""Emitted when a node calls the pytest_collectreport hook.
|
||||
|
||||
Because we only need the report when there's a failure/skip, as optimization
|
||||
@@ -322,14 +367,20 @@ class DSession:
|
||||
assert not rep.passed
|
||||
self._failed_worker_collectreport(node, rep)
|
||||
|
||||
def worker_warning_recorded(self, warning_message, when, nodeid, location):
|
||||
def worker_warning_recorded(
|
||||
self,
|
||||
warning_message: warnings.WarningMessage,
|
||||
when: str,
|
||||
nodeid: str,
|
||||
location: tuple[str, int, str] | None,
|
||||
) -> None:
|
||||
"""Emitted when a node calls the pytest_warning_recorded hook."""
|
||||
kwargs = dict(
|
||||
warning_message=warning_message, when=when, nodeid=nodeid, location=location
|
||||
)
|
||||
self.config.hook.pytest_warning_recorded.call_historic(kwargs=kwargs)
|
||||
|
||||
def _clone_node(self, node):
|
||||
def _clone_node(self, node: WorkerController) -> WorkerController:
|
||||
"""Return new node based on an existing one.
|
||||
|
||||
This is normally for when a node dies, this will copy the spec
|
||||
@@ -339,12 +390,17 @@ class DSession:
|
||||
"""
|
||||
spec = node.gateway.spec
|
||||
spec.id = None
|
||||
assert self.nodemanager is not None
|
||||
self.nodemanager.group.allocate_id(spec)
|
||||
node = self.nodemanager.setup_node(spec, self.queue.put)
|
||||
self._active_nodes.add(node)
|
||||
return node
|
||||
clone = self.nodemanager.setup_node(spec, self.queue.put)
|
||||
self._active_nodes.add(clone)
|
||||
return clone
|
||||
|
||||
def _failed_worker_collectreport(self, node, rep):
|
||||
def _failed_worker_collectreport(
|
||||
self,
|
||||
node: WorkerController,
|
||||
rep: pytest.CollectReport | pytest.TestReport,
|
||||
) -> None:
|
||||
# Check we haven't already seen this report (from
|
||||
# another worker).
|
||||
if rep.longrepr not in self._failed_collection_errors:
|
||||
@@ -352,7 +408,10 @@ class DSession:
|
||||
self.config.hook.pytest_collectreport(report=rep)
|
||||
self._handlefailures(rep)
|
||||
|
||||
def _handlefailures(self, rep):
|
||||
def _handlefailures(
|
||||
self,
|
||||
rep: pytest.CollectReport | pytest.TestReport,
|
||||
) -> None:
|
||||
if rep.failed:
|
||||
self.countfailures += 1
|
||||
if (
|
||||
@@ -362,22 +421,28 @@ class DSession:
|
||||
):
|
||||
self.shouldstop = f"stopping after {self.countfailures} failures"
|
||||
|
||||
def triggershutdown(self):
|
||||
def triggershutdown(self) -> None:
|
||||
if not self.shuttingdown:
|
||||
self.log("triggering shutdown")
|
||||
self.shuttingdown = True
|
||||
assert self.sched is not None
|
||||
for node in self.sched.nodes:
|
||||
node.shutdown()
|
||||
|
||||
def handle_crashitem(self, nodeid, worker):
|
||||
def handle_crashitem(self, nodeid: str, worker: WorkerController) -> None:
|
||||
# XXX get more reporting info by recording pytest_runtest_logstart?
|
||||
# XXX count no of failures and retry N times
|
||||
fspath = nodeid.split("::")[0]
|
||||
msg = f"worker {worker.gateway.id!r} crashed while running {nodeid!r}"
|
||||
rep = pytest.TestReport(
|
||||
nodeid, (fspath, None, fspath), (), "failed", msg, "???"
|
||||
nodeid=nodeid,
|
||||
location=(fspath, None, fspath),
|
||||
keywords={},
|
||||
outcome="failed",
|
||||
longrepr=msg,
|
||||
when="???", # type: ignore[arg-type]
|
||||
)
|
||||
rep.node = worker
|
||||
rep.node = worker # type: ignore[attr-defined]
|
||||
|
||||
self.config.hook.pytest_handlecrashitem(
|
||||
crashitem=nodeid,
|
||||
@@ -404,10 +469,10 @@ class WorkerStatus(Enum):
|
||||
|
||||
|
||||
class TerminalDistReporter:
|
||||
def __init__(self, config) -> None:
|
||||
def __init__(self, config: pytest.Config) -> None:
|
||||
self.config = config
|
||||
self.tr = config.pluginmanager.getplugin("terminalreporter")
|
||||
self._status: dict[str, tuple[WorkerStatus, int]] = {}
|
||||
self._status: dict[object, tuple[WorkerStatus, int]] = {}
|
||||
self._lastlen = 0
|
||||
self._isatty = getattr(self.tr, "isatty", self.tr.hasmarkup)
|
||||
|
||||
@@ -419,7 +484,12 @@ class TerminalDistReporter:
|
||||
self.write_line(self.getstatus())
|
||||
|
||||
def setstatus(
|
||||
self, spec, status: WorkerStatus, *, tests_collected: int, show: bool = True
|
||||
self,
|
||||
spec: execnet.XSpec,
|
||||
status: WorkerStatus,
|
||||
*,
|
||||
tests_collected: int,
|
||||
show: bool = True,
|
||||
) -> None:
|
||||
self._status[spec.id] = (status, tests_collected)
|
||||
if show and self._isatty:
|
||||
@@ -433,7 +503,7 @@ class TerminalDistReporter:
|
||||
|
||||
return "bringing up nodes..."
|
||||
|
||||
def rewrite(self, line, newline=False):
|
||||
def rewrite(self, line: str, newline: bool = False) -> None:
|
||||
pline = line + " " * max(self._lastlen - len(line), 0)
|
||||
if newline:
|
||||
self._lastlen = 0
|
||||
@@ -443,7 +513,7 @@ class TerminalDistReporter:
|
||||
self.tr.rewrite(pline, bold=True)
|
||||
|
||||
@pytest.hookimpl
|
||||
def pytest_xdist_setupnodes(self, specs) -> None:
|
||||
def pytest_xdist_setupnodes(self, specs: Sequence[execnet.XSpec]) -> None:
|
||||
self._specs = specs
|
||||
for spec in specs:
|
||||
self.setstatus(spec, WorkerStatus.Created, tests_collected=0, show=False)
|
||||
@@ -451,7 +521,7 @@ class TerminalDistReporter:
|
||||
self.ensure_show_status()
|
||||
|
||||
@pytest.hookimpl
|
||||
def pytest_xdist_newgateway(self, gateway) -> None:
|
||||
def pytest_xdist_newgateway(self, gateway: execnet.Gateway) -> None:
|
||||
if self.config.option.verbose > 0:
|
||||
rinfo = gateway._rinfo()
|
||||
different_interpreter = rinfo.executable != sys.executable
|
||||
@@ -464,7 +534,7 @@ class TerminalDistReporter:
|
||||
self.setstatus(gateway.spec, WorkerStatus.Initialized, tests_collected=0)
|
||||
|
||||
@pytest.hookimpl
|
||||
def pytest_testnodeready(self, node) -> None:
|
||||
def pytest_testnodeready(self, node: WorkerController) -> None:
|
||||
if self.config.option.verbose > 0:
|
||||
d = node.workerinfo
|
||||
different_interpreter = d.get("executable") != sys.executable
|
||||
@@ -476,23 +546,25 @@ class TerminalDistReporter:
|
||||
)
|
||||
|
||||
@pytest.hookimpl
|
||||
def pytest_testnodedown(self, node, error) -> None:
|
||||
def pytest_testnodedown(self, node: WorkerController, error: object) -> None:
|
||||
if not error:
|
||||
return
|
||||
self.write_line(f"[{node.gateway.id}] node down: {error}")
|
||||
|
||||
|
||||
def get_default_max_worker_restart(config):
|
||||
def get_default_max_worker_restart(config: pytest.Config) -> int | None:
|
||||
"""Gets the default value of --max-worker-restart option if it is not provided.
|
||||
|
||||
Use a reasonable default to avoid workers from restarting endlessly due to crashing collections (#226).
|
||||
"""
|
||||
result = config.option.maxworkerrestart
|
||||
if result is not None:
|
||||
result = int(result)
|
||||
result_str: str | None = config.option.maxworkerrestart
|
||||
if result_str is not None:
|
||||
result = int(result_str)
|
||||
elif config.option.numprocesses:
|
||||
# if --max-worker-restart was not provided, use a reasonable default (#226)
|
||||
result = config.option.numprocesses * 4
|
||||
else:
|
||||
result = None
|
||||
return result
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user