Skip to content

Commit 3f09e15

Browse files
authored
Merge pull request #242 from Corni/loadfilescope
Load-Distribute test cases by filename
2 parents efce50f + 0d19e7f commit 3f09e15

File tree

8 files changed

+132
-3
lines changed

8 files changed

+132
-3
lines changed

README.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,22 @@ the same worker ``gw0``, while the test methods from classes ``TestHDF`` and
321321
Currently the groupings can't be customized, with grouping by class takes
322322
priority over grouping by module.
323323

324+
Sending tests to the same worker based on their file
325+
++++++++++++++++++++++++++++++++++++++++++++++++++++
326+
327+
*New in version 1.21.*
328+
329+
.. note::
330+
This is an **experimental** feature: the actual functionality will
331+
likely stay the same, but the CLI might change slightly in future versions.
332+
333+
You can send tests to the same worker grouped by their filename by using the
334+
``--dist=loadfile`` option, so tests of the same file are guaranteed to run
335+
in the same worker.
336+
337+
Using the example in the previous section, all tests from ``test_container.py`` will
338+
run in the same worker, as well as the tests in ``test_io.py``.
339+
324340

325341
Specifying "rsync" dirs in an ini-file
326342
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

changelog/242.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
New ``--dist=loadfile`` option which load-distributes test to workers grouped by the file the tests live in.

testing/acceptance_test.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,61 @@ def test(self, i):
856856
'test_a.py::TestB', result.outlines) in ({'gw0': 10}, {'gw1': 10})
857857

858858

859+
class TestFileScope:
860+
861+
def test_by_module(self, testdir):
862+
test_file = """
863+
import pytest
864+
class TestA:
865+
@pytest.mark.parametrize('i', range(10))
866+
def test(self, i):
867+
pass
868+
869+
class TestB:
870+
@pytest.mark.parametrize('i', range(10))
871+
def test(self, i):
872+
pass
873+
"""
874+
testdir.makepyfile(
875+
test_a=test_file,
876+
test_b=test_file,
877+
)
878+
result = testdir.runpytest('-n2', '--dist=loadfile', '-v')
879+
test_a_workers_and_test_count = get_workers_and_test_count_by_prefix(
880+
'test_a.py::TestA', result.outlines)
881+
test_b_workers_and_test_count = get_workers_and_test_count_by_prefix(
882+
'test_b.py::TestB', result.outlines)
883+
884+
assert test_a_workers_and_test_count in ({'gw0': 10}, {'gw1': 0}) or \
885+
test_a_workers_and_test_count in ({'gw0': 0}, {'gw1': 10})
886+
assert test_b_workers_and_test_count in ({'gw0': 10}, {'gw1': 0}) or \
887+
test_b_workers_and_test_count in ({'gw0': 0}, {'gw1': 10})
888+
889+
def test_by_class(self, testdir):
890+
testdir.makepyfile(test_a="""
891+
import pytest
892+
class TestA:
893+
@pytest.mark.parametrize('i', range(10))
894+
def test(self, i):
895+
pass
896+
897+
class TestB:
898+
@pytest.mark.parametrize('i', range(10))
899+
def test(self, i):
900+
pass
901+
""")
902+
result = testdir.runpytest('-n2', '--dist=loadfile', '-v')
903+
test_a_workers_and_test_count = get_workers_and_test_count_by_prefix(
904+
'test_a.py::TestA', result.outlines)
905+
test_b_workers_and_test_count = get_workers_and_test_count_by_prefix(
906+
'test_a.py::TestB', result.outlines)
907+
908+
assert test_a_workers_and_test_count in ({'gw0': 10}, {'gw1': 0}) or \
909+
test_a_workers_and_test_count in ({'gw0': 0}, {'gw1': 10})
910+
assert test_b_workers_and_test_count in ({'gw0': 10}, {'gw1': 0}) or \
911+
test_b_workers_and_test_count in ({'gw0': 0}, {'gw1': 10})
912+
913+
859914
def parse_tests_and_workers_from_output(lines):
860915
result = []
861916
for line in lines:

xdist/dsession.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
EachScheduling,
77
LoadScheduling,
88
LoadScopeScheduling,
9+
LoadFileScheduling,
910
)
1011

1112

@@ -99,6 +100,7 @@ def pytest_xdist_make_scheduler(self, config, log):
99100
'each': EachScheduling,
100101
'load': LoadScheduling,
101102
'loadscope': LoadScopeScheduling,
103+
'loadfile': LoadFileScheduling,
102104
}
103105
return schedulers[dist](config, log)
104106

xdist/plugin.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,16 @@ def pytest_addoption(parser):
3131
"when crashed (set to zero to disable this feature)")
3232
group.addoption(
3333
'--dist', metavar="distmode",
34-
action="store", choices=['each', 'load', 'loadscope', 'no'],
34+
action="store", choices=['each', 'load', 'loadscope', 'loadfile', 'no'],
3535
dest="dist", default="no",
3636
help=("set mode for distributing tests to exec environments.\n\n"
3737
"each: send each test to all available environments.\n\n"
3838
"load: load balance by sending any pending test to any"
3939
" available environment.\n\n"
4040
"loadscope: load balance by sending pending groups of tests in"
4141
" the same scope to any available environment.\n\n"
42+
"loadfile: load balance by sending test grouped by file"
43+
" to any available environment.\n\n"
4244
"(default) no: run tests inprocess, don't distribute."))
4345
group.addoption(
4446
'--tx', dest="tx", action="append", default=[],

xdist/scheduler/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
from xdist.scheduler.each import EachScheduling # noqa
22
from xdist.scheduler.load import LoadScheduling # noqa
33
from xdist.scheduler.loadscope import LoadScopeScheduling # noqa
4+
from xdist.scheduler.filescope import LoadFileScheduling # noqa

xdist/scheduler/filescope.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from . import LoadScopeScheduling
2+
from py.log import Producer
3+
4+
5+
class LoadFileScheduling(LoadScopeScheduling):
6+
"""Implement load scheduling across nodes, but grouping test test file.
7+
8+
This distributes the tests collected across all nodes so each test is run
9+
just once. All nodes collect and submit the list of tests and when all
10+
collections are received it is verified they are identical collections.
11+
Then the collection gets divided up in work units, grouped by test file,
12+
and those work units get submitted to nodes. Whenever a node finishes an
13+
item, it calls ``.mark_test_complete()`` which will trigger the scheduler
14+
to assign more work units if the number of pending tests for the node falls
15+
below a low-watermark.
16+
17+
When created, ``numnodes`` defines how many nodes are expected to submit a
18+
collection. This is used to know when all nodes have finished collection.
19+
20+
This class behaves very much like LoadScopeScheduling, but with a file-level scope.
21+
"""
22+
23+
def __init(self, config, log=None):
24+
super(LoadFileScheduling, self).__init__(config, log)
25+
if log is None:
26+
self.log = Producer('loadfilesched')
27+
else:
28+
self.log = log.loadfilesched
29+
30+
def _split_scope(self, nodeid):
31+
"""Determine the scope (grouping) of a nodeid.
32+
33+
There are usually 3 cases for a nodeid::
34+
35+
example/loadsuite/test/test_beta.py::test_beta0
36+
example/loadsuite/test/test_delta.py::Delta1::test_delta0
37+
example/loadsuite/epsilon/__init__.py::epsilon.epsilon
38+
39+
#. Function in a test module.
40+
#. Method of a class in a test module.
41+
#. Doctest in a function in a package.
42+
43+
This function will group tests with the scope determined by splitting
44+
the first ``::`` from the left. That is, test will be grouped in a
45+
single work unit when they reside in the same file.
46+
In the above example, scopes will be::
47+
48+
example/loadsuite/test/test_beta.py
49+
example/loadsuite/test/test_delta.py
50+
example/loadsuite/epsilon/__init__.py
51+
"""
52+
return nodeid.split('::', 1)[0]

xdist/scheduler/loadscope.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,12 +368,12 @@ def schedule(self):
368368
extra_nodes = len(self.nodes) - len(self.workqueue)
369369

370370
if extra_nodes > 0:
371-
self.log('Shuting down {} nodes'.format(extra_nodes))
371+
self.log('Shuting down {0} nodes'.format(extra_nodes))
372372

373373
for _ in range(extra_nodes):
374374
unused_node, assigned = self.assigned_work.popitem(last=True)
375375

376-
self.log('Shuting down unused node {}'.format(unused_node))
376+
self.log('Shuting down unused node {0}'.format(unused_node))
377377
unused_node.shutdown()
378378

379379
# Assign initial workload

0 commit comments

Comments
 (0)