Skip to content

Commit be1d5c4

Browse files
authored
Make collection progress output less verbose (#901)
Fix #555
1 parent e48dc3c commit be1d5c4

File tree

4 files changed

+190
-41
lines changed

4 files changed

+190
-41
lines changed

changelog/555.improvement.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improved progress output when collecting nodes to be less verbose.

src/xdist/dsession.py

+89-26
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
from __future__ import annotations
2+
import sys
3+
from enum import Enum, auto
4+
from typing import Sequence
5+
16
import pytest
27

38
from xdist.remote import Producer
@@ -251,14 +256,16 @@ def worker_collectionfinish(self, node, ids):
251256
self._session.testscollected = len(ids)
252257
self.sched.add_node_collection(node, ids)
253258
if self.terminal:
254-
self.trdist.setstatus(node.gateway.spec, "[%d]" % (len(ids)))
259+
self.trdist.setstatus(
260+
node.gateway.spec, WorkerStatus.CollectionDone, tests_collected=len(ids)
261+
)
255262
if self.sched.collection_is_completed:
256263
if self.terminal and not self.sched.has_pending:
257264
self.trdist.ensure_show_status()
258265
self.terminal.write_line("")
259266
if self.config.option.verbose > 0:
260267
self.terminal.write_line(
261-
"scheduling tests via %s" % (self.sched.__class__.__name__)
268+
f"scheduling tests via {self.sched.__class__.__name__}"
262269
)
263270
self.sched.schedule()
264271

@@ -345,7 +352,7 @@ def _handlefailures(self, rep):
345352
if rep.failed:
346353
self.countfailures += 1
347354
if self.maxfail and self.countfailures >= self.maxfail:
348-
self.shouldstop = "stopping after %d failures" % (self.countfailures)
355+
self.shouldstop = f"stopping after {self.countfailures} failures"
349356

350357
def triggershutdown(self):
351358
self.log("triggering shutdown")
@@ -372,32 +379,51 @@ def handle_crashitem(self, nodeid, worker):
372379
self.config.hook.pytest_runtest_logreport(report=rep)
373380

374381

382+
class WorkerStatus(Enum):
383+
"""Status of each worker during creation/collection."""
384+
385+
# Worker spec has just been created.
386+
Created = auto()
387+
388+
# Worker has been initialized.
389+
Initialized = auto()
390+
391+
# Worker is now ready for collection.
392+
ReadyForCollection = auto()
393+
394+
# Worker has finished collection.
395+
CollectionDone = auto()
396+
397+
375398
class TerminalDistReporter:
376-
def __init__(self, config):
399+
def __init__(self, config) -> None:
377400
self.config = config
378401
self.tr = config.pluginmanager.getplugin("terminalreporter")
379-
self._status = {}
402+
self._status: dict[str, tuple[WorkerStatus, int]] = {}
380403
self._lastlen = 0
381404
self._isatty = getattr(self.tr, "isatty", self.tr.hasmarkup)
382405

383-
def write_line(self, msg):
406+
def write_line(self, msg: str) -> None:
384407
self.tr.write_line(msg)
385408

386-
def ensure_show_status(self):
409+
def ensure_show_status(self) -> None:
387410
if not self._isatty:
388411
self.write_line(self.getstatus())
389412

390-
def setstatus(self, spec, status, show=True):
391-
self._status[spec.id] = status
413+
def setstatus(
414+
self, spec, status: WorkerStatus, *, tests_collected: int, show: bool = True
415+
) -> None:
416+
self._status[spec.id] = (status, tests_collected)
392417
if show and self._isatty:
393418
self.rewrite(self.getstatus())
394419

395-
def getstatus(self):
420+
def getstatus(self) -> str:
396421
if self.config.option.verbose >= 0:
397-
parts = [f"{spec.id} {self._status[spec.id]}" for spec in self._specs]
398-
return " / ".join(parts)
399-
else:
400-
return "bringing up nodes..."
422+
line = get_workers_status_line(list(self._status.values()))
423+
if line:
424+
return line
425+
426+
return "bringing up nodes..."
401427

402428
def rewrite(self, line, newline=False):
403429
pline = line + " " * max(self._lastlen - len(line), 0)
@@ -409,37 +435,41 @@ def rewrite(self, line, newline=False):
409435
self.tr.rewrite(pline, bold=True)
410436

411437
@pytest.hookimpl
412-
def pytest_xdist_setupnodes(self, specs):
438+
def pytest_xdist_setupnodes(self, specs) -> None:
413439
self._specs = specs
414440
for spec in specs:
415-
self.setstatus(spec, "I", show=False)
416-
self.setstatus(spec, "I", show=True)
441+
self.setstatus(spec, WorkerStatus.Created, tests_collected=0, show=False)
442+
self.setstatus(spec, WorkerStatus.Created, tests_collected=0, show=True)
417443
self.ensure_show_status()
418444

419445
@pytest.hookimpl
420-
def pytest_xdist_newgateway(self, gateway):
421-
if self.config.option.verbose > 0:
422-
rinfo = gateway._rinfo()
446+
def pytest_xdist_newgateway(self, gateway) -> None:
447+
rinfo = gateway._rinfo()
448+
is_local = rinfo.executable == sys.executable
449+
if self.config.option.verbose > 0 and not is_local:
423450
version = "%s.%s.%s" % rinfo.version_info[:3]
424451
self.rewrite(
425452
"[%s] %s Python %s cwd: %s"
426453
% (gateway.id, rinfo.platform, version, rinfo.cwd),
427454
newline=True,
428455
)
429-
self.setstatus(gateway.spec, "C")
456+
self.setstatus(gateway.spec, WorkerStatus.Initialized, tests_collected=0)
430457

431458
@pytest.hookimpl
432-
def pytest_testnodeready(self, node):
433-
if self.config.option.verbose > 0:
434-
d = node.workerinfo
459+
def pytest_testnodeready(self, node) -> None:
460+
d = node.workerinfo
461+
is_local = d.get("executable") == sys.executable
462+
if self.config.option.verbose > 0 and not is_local:
435463
infoline = "[{}] Python {}".format(
436464
d["id"], d["version"].replace("\n", " -- ")
437465
)
438466
self.rewrite(infoline, newline=True)
439-
self.setstatus(node.gateway.spec, "ok")
467+
self.setstatus(
468+
node.gateway.spec, WorkerStatus.ReadyForCollection, tests_collected=0
469+
)
440470

441471
@pytest.hookimpl
442-
def pytest_testnodedown(self, node, error):
472+
def pytest_testnodedown(self, node, error) -> None:
443473
if not error:
444474
return
445475
self.write_line(f"[{node.gateway.id}] node down: {error}")
@@ -457,3 +487,36 @@ def get_default_max_worker_restart(config):
457487
# if --max-worker-restart was not provided, use a reasonable default (#226)
458488
result = config.option.numprocesses * 4
459489
return result
490+
491+
492+
def get_workers_status_line(
493+
status_and_items: Sequence[tuple[WorkerStatus, int]]
494+
) -> str:
495+
"""
496+
Return the line to display during worker setup/collection based on the
497+
status of the workers and number of tests collected for each.
498+
"""
499+
statuses = [s for s, c in status_and_items]
500+
total_workers = len(statuses)
501+
workers_noun = "worker" if total_workers == 1 else "workers"
502+
if status_and_items and all(s == WorkerStatus.CollectionDone for s in statuses):
503+
# All workers collect the same number of items, so we grab
504+
# the total number of items from the first worker.
505+
first = status_and_items[0]
506+
status, tests_collected = first
507+
tests_noun = "item" if tests_collected == 1 else "items"
508+
return f"{total_workers} {workers_noun} [{tests_collected} {tests_noun}]"
509+
if WorkerStatus.CollectionDone in statuses:
510+
done = sum(1 for s, c in status_and_items if c > 0)
511+
return f"collecting: {done}/{total_workers} {workers_noun}"
512+
if WorkerStatus.ReadyForCollection in statuses:
513+
ready = statuses.count(WorkerStatus.ReadyForCollection)
514+
return f"ready: {ready}/{total_workers} {workers_noun}"
515+
if WorkerStatus.Initialized in statuses:
516+
initialized = statuses.count(WorkerStatus.Initialized)
517+
return f"initialized: {initialized}/{total_workers} {workers_noun}"
518+
if WorkerStatus.Created in statuses:
519+
created = statuses.count(WorkerStatus.Created)
520+
return f"created: {created}/{total_workers} {workers_noun}"
521+
522+
return ""

testing/acceptance_test.py

+24-12
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,12 @@ def test_skip():
101101
"""
102102
)
103103
result = pytester.runpytest(p1, "-v", "-d", "--tx=popen", "--tx=popen")
104-
result.stdout.fnmatch_lines(["*1*Python*", "*2 failed, 1 passed, 1 skipped*"])
104+
result.stdout.fnmatch_lines(
105+
[
106+
"created: 2/2 workers",
107+
"*2 failed, 1 passed, 1 skipped*",
108+
]
109+
)
105110
assert result.ret == 1
106111

107112
def test_n1_fail_minus_x(self, pytester: pytest.Pytester) -> None:
@@ -151,7 +156,12 @@ def test_skip():
151156
"""
152157
)
153158
result = pytester.runpytest(p1, "-d", "-v")
154-
result.stdout.fnmatch_lines(["*2*Python*", "*2 failed, 1 passed, 1 skipped*"])
159+
result.stdout.fnmatch_lines(
160+
[
161+
"created: 3/3 workers",
162+
"*2 failed, 1 passed, 1 skipped*",
163+
]
164+
)
155165
assert result.ret == 1
156166

157167
def test_dist_tests_with_crash(self, pytester: pytest.Pytester) -> None:
@@ -237,9 +247,6 @@ def pytest_load_initial_conftests(early_config):
237247
assert result.ret == 0
238248
result.stdout.fnmatch_lines(
239249
[
240-
"*0* *cwd*",
241-
# "RSyncStart: [G1]",
242-
# "RSyncFinished: [G1]",
243250
"*1 passed*",
244251
]
245252
)
@@ -276,7 +283,11 @@ def pytest_terminal_summary(terminalreporter):
276283
p1 = pytester.makepyfile("def test_func(): pass")
277284
result = pytester.runpytest("-v", p1, "-d", "--tx=popen")
278285
result.stdout.fnmatch_lines(
279-
["*0*Python*", "*calculated result is 49*", "*1 passed*"]
286+
[
287+
"created: 1/1 worker",
288+
"*calculated result is 49*",
289+
"*1 passed*",
290+
]
280291
)
281292
assert result.ret == 0
282293

@@ -393,14 +404,14 @@ def test_ok():
393404
out = result.stdout.str()
394405
if verbosity == "-v":
395406
assert "scheduling tests" in out
396-
assert "gw" in out
407+
assert "1 worker [1 item]" in out
397408
elif verbosity == "-q":
398409
assert "scheduling tests" not in out
399410
assert "gw" not in out
400411
assert "bringing up nodes..." in out
401412
else:
402413
assert "scheduling tests" not in out
403-
assert "gw" in out
414+
assert "1 worker [1 item]" in out
404415

405416
def test_pass_skip_fail(self, pytester: pytest.Pytester) -> None:
406417
pytester.makepyfile(
@@ -1099,8 +1110,9 @@ def test_this(i):
10991110
result = pytester.runpytest(*args)
11001111
assert "test session starts" in result.stdout.str()
11011112
assert "\x1b[1m" in result.stdout.str()
1102-
assert "gw0 [10] / gw1 [10]" in result.stdout.str()
1103-
assert "gw0 C / gw1 C" not in result.stdout.str()
1113+
assert "created: 2/2 workers" in result.stdout.str()
1114+
assert "2 workers [10 items]" in result.stdout.str()
1115+
assert "collecting:" not in result.stdout.str()
11041116

11051117

11061118
def test_without_terminal_plugin(pytester, request) -> None:
@@ -1554,8 +1566,8 @@ def test_collection_crash(testdir):
15541566
assert result.ret == 1
15551567
result.stdout.fnmatch_lines(
15561568
[
1557-
"gw0 I",
1558-
"gw0 [[]0[]]",
1569+
"created: 1/1 worker",
1570+
"1 worker [[]0 items[]]",
15591571
"*_ ERROR collecting test_collection_crash.py _*",
15601572
"E assert 0",
15611573
"*= 1 error in *",

testing/test_dsession.py

+76-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
from xdist.dsession import DSession, get_default_max_worker_restart
1+
from __future__ import annotations
2+
from xdist.dsession import (
3+
DSession,
4+
get_default_max_worker_restart,
5+
get_workers_status_line,
6+
WorkerStatus,
7+
)
28
from xdist.report import report_collection_diff
39
from xdist.scheduler import EachScheduling, LoadScheduling, WorkStealingScheduling
4-
from typing import Optional
10+
from typing import Sequence
511

612
import pytest
713
import execnet
@@ -473,7 +479,7 @@ def test_report_collection_diff_equal() -> None:
473479
def test_default_max_worker_restart() -> None:
474480
class config:
475481
class option:
476-
maxworkerrestart: Optional[str] = None
482+
maxworkerrestart: str | None = None
477483
numprocesses: int = 0
478484

479485
assert get_default_max_worker_restart(config) is None
@@ -527,3 +533,70 @@ def test_2011_table(birth_year):
527533
reprec = pytester.inline_run("-n1")
528534
reprec.assertoutcome(passed=2)
529535
assert 0
536+
537+
538+
Created = WorkerStatus.Created
539+
Initialized = WorkerStatus.Initialized
540+
ReadyForCollection = WorkerStatus.ReadyForCollection
541+
CollectionDone = WorkerStatus.CollectionDone
542+
543+
544+
@pytest.mark.parametrize(
545+
"status_and_items, expected",
546+
[
547+
(
548+
[],
549+
"",
550+
),
551+
(
552+
[(Created, 0)],
553+
"created: 1/1 worker",
554+
),
555+
(
556+
[(Created, 0), (Created, 0)],
557+
"created: 2/2 workers",
558+
),
559+
(
560+
[(Initialized, 0), (Created, 0)],
561+
"initialized: 1/2 workers",
562+
),
563+
(
564+
[(Initialized, 0), (Initialized, 0)],
565+
"initialized: 2/2 workers",
566+
),
567+
(
568+
[(ReadyForCollection, 0), (Created, 0)],
569+
"ready: 1/2 workers",
570+
),
571+
(
572+
[(ReadyForCollection, 0), (ReadyForCollection, 0)],
573+
"ready: 2/2 workers",
574+
),
575+
(
576+
[(CollectionDone, 12), (Created, 0)],
577+
"collecting: 1/2 workers",
578+
),
579+
(
580+
[(CollectionDone, 12), (CollectionDone, 12)],
581+
"2 workers [12 items]",
582+
),
583+
(
584+
[(CollectionDone, 1), (CollectionDone, 1)],
585+
"2 workers [1 item]",
586+
),
587+
(
588+
[(CollectionDone, 1)],
589+
"1 worker [1 item]",
590+
),
591+
# Different number of tests collected will raise an error and should not happen in practice,
592+
# but we test for it anyway.
593+
(
594+
[(CollectionDone, 1), (CollectionDone, 12)],
595+
"2 workers [1 item]",
596+
),
597+
],
598+
)
599+
def test_get_workers_status_line(
600+
status_and_items: Sequence[tuple[WorkerStatus, int]], expected: str
601+
) -> None:
602+
assert get_workers_status_line(status_and_items) == expected

0 commit comments

Comments
 (0)