Skip to content

Commit 630c1eb

Browse files
authored
Merge pull request #733 from baekdohyeop/feature-loadgroup
2 parents 408eb1c + 62e50d0 commit 630c1eb

File tree

8 files changed

+231
-1
lines changed

8 files changed

+231
-1
lines changed

README.rst

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ distribution algorithm this with the ``--dist`` option. It takes these values:
9696
distributed to available workers as whole units. This guarantees that all
9797
tests in a file run in the same worker.
9898

99+
* ``--dist loadgroup``: Tests are grouped by xdist_group mark. Groups are
100+
distributed to available workers as whole units. This guarantees that all
101+
tests with same xdist_group name run in the same worker.
102+
99103
Making session-scoped fixtures execute only once
100104
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
101105

@@ -414,3 +418,21 @@ where the configuration file was found.
414418
.. _`pytest-xdist`: http://pypi.python.org/pypi/pytest-xdist
415419
.. _`pytest-xdist repository`: https://github.com/pytest-dev/pytest-xdist
416420
.. _`pytest`: http://pytest.org
421+
422+
Groups tests by xdist_group mark
423+
---------------------------------
424+
425+
*New in version 2.4.*
426+
427+
Two or more tests belonging to different classes or modules can be executed in same worker through the xdist_group marker:
428+
429+
.. code-block:: python
430+
431+
@pytest.mark.xdist_group(name="group1")
432+
def test1():
433+
pass
434+
435+
class TestA:
436+
@pytest.mark.xdist_group("group1")
437+
def test2():
438+
pass

changelog/733.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
New ``--dist=loadgroup`` option, which ensures all tests marked with ``@pytest.mark.xdist_group`` run in the same session/worker. Other tests run distributed as in ``--dist=load``.

src/xdist/dsession.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
LoadScheduling,
88
LoadScopeScheduling,
99
LoadFileScheduling,
10+
LoadGroupScheduling,
1011
)
1112

1213

@@ -100,6 +101,7 @@ def pytest_xdist_make_scheduler(self, config, log):
100101
"load": LoadScheduling,
101102
"loadscope": LoadScopeScheduling,
102103
"loadfile": LoadFileScheduling,
104+
"loadgroup": LoadGroupScheduling,
103105
}
104106
return schedulers[dist](config, log)
105107

src/xdist/plugin.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def pytest_addoption(parser):
8686
"--dist",
8787
metavar="distmode",
8888
action="store",
89-
choices=["each", "load", "loadscope", "loadfile", "no"],
89+
choices=["each", "load", "loadscope", "loadfile", "loadgroup", "no"],
9090
dest="dist",
9191
default="no",
9292
help=(
@@ -98,6 +98,7 @@ def pytest_addoption(parser):
9898
" the same scope to any available environment.\n\n"
9999
"loadfile: load balance by sending test grouped by file"
100100
" to any available environment.\n\n"
101+
"loadgroup: like load, but sends tests marked with 'xdist_group' to the same worker.\n\n"
101102
"(default) no: run tests inprocess, don't distribute."
102103
),
103104
)
@@ -204,6 +205,12 @@ def pytest_configure(config):
204205
config.issue_config_time_warning(warning, 2)
205206
config.option.forked = True
206207

208+
config_line = (
209+
"xdist_group: specify group for tests should run in same session."
210+
"in relation to one another. " + "Provided by pytest-xdist."
211+
)
212+
config.addinivalue_line("markers", config_line)
213+
207214

208215
@pytest.hookimpl(tryfirst=True)
209216
def pytest_cmdline_main(config):

src/xdist/remote.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,20 @@ def run_one_test(self, torun):
116116
"runtest_protocol_complete", item_index=self.item_index, duration=duration
117117
)
118118

119+
def pytest_collection_modifyitems(self, session, config, items):
120+
# add the group name to nodeid as suffix if --dist=loadgroup
121+
if config.getvalue("loadgroup"):
122+
for item in items:
123+
mark = item.get_closest_marker("xdist_group")
124+
if not mark:
125+
continue
126+
gname = (
127+
mark.args[0]
128+
if len(mark.args) > 0
129+
else mark.kwargs.get("name", "default")
130+
)
131+
item._nodeid = "{}@{}".format(item.nodeid, gname)
132+
119133
@pytest.hookimpl
120134
def pytest_collection_finish(self, session):
121135
try:
@@ -236,6 +250,7 @@ def remote_initconfig(option_dict, args):
236250

237251

238252
def setup_config(config, basetemp):
253+
config.option.loadgroup = config.getvalue("dist") == "loadgroup"
239254
config.option.looponfail = False
240255
config.option.usepdb = False
241256
config.option.dist = "no"

src/xdist/scheduler/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
from xdist.scheduler.load import LoadScheduling # noqa
33
from xdist.scheduler.loadfile import LoadFileScheduling # noqa
44
from xdist.scheduler.loadscope import LoadScopeScheduling # noqa
5+
from xdist.scheduler.loadgroup import LoadGroupScheduling # noqa

src/xdist/scheduler/loadgroup.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from .loadscope import LoadScopeScheduling
2+
from py.log import Producer
3+
4+
5+
class LoadGroupScheduling(LoadScopeScheduling):
6+
"""Implement load scheduling across nodes, but grouping test by xdist_group mark.
7+
8+
This class behaves very much like LoadScopeScheduling, but it groups tests by xdist_group mark
9+
instead of the module or class to which they belong to.
10+
"""
11+
12+
def __init__(self, config, log=None):
13+
super().__init__(config, log)
14+
if log is None:
15+
self.log = Producer("loadgroupsched")
16+
else:
17+
self.log = log.loadgroupsched
18+
19+
def _split_scope(self, nodeid):
20+
"""Determine the scope (grouping) of a nodeid.
21+
22+
There are usually 3 cases for a nodeid::
23+
24+
example/loadsuite/test/test_beta.py::test_beta0
25+
example/loadsuite/test/test_delta.py::Delta1::test_delta0
26+
example/loadsuite/epsilon/__init__.py::epsilon.epsilon
27+
28+
#. Function in a test module.
29+
#. Method of a class in a test module.
30+
#. Doctest in a function in a package.
31+
32+
With loadgroup, two cases are added::
33+
34+
example/loadsuite/test/test_beta.py::test_beta0
35+
example/loadsuite/test/test_delta.py::Delta1::test_delta0
36+
example/loadsuite/epsilon/__init__.py::epsilon.epsilon
37+
example/loadsuite/test/test_gamma.py::test_beta0@gname
38+
example/loadsuite/test/test_delta.py::Gamma1::test_gamma0@gname
39+
40+
This function will group tests with the scope determined by splitting the first ``@``
41+
from the right. That is, test will be grouped in a single work unit when they have
42+
same group name. In the above example, scopes will be::
43+
44+
example/loadsuite/test/test_beta.py::test_beta0
45+
example/loadsuite/test/test_delta.py::Delta1::test_delta0
46+
example/loadsuite/epsilon/__init__.py::epsilon.epsilon
47+
gname
48+
gname
49+
"""
50+
if nodeid.rfind("@") > nodeid.rfind("]"):
51+
# check the index of ']' to avoid the case: parametrize mark value has '@'
52+
return nodeid.split("@")[-1]
53+
else:
54+
return nodeid

testing/acceptance_test.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,6 +1326,134 @@ def test_2():
13261326
assert c1 == c2
13271327

13281328

1329+
class TestGroupScope:
1330+
def test_by_module(self, testdir):
1331+
test_file = """
1332+
import pytest
1333+
class TestA:
1334+
@pytest.mark.xdist_group(name="xdist_group")
1335+
@pytest.mark.parametrize('i', range(5))
1336+
def test(self, i):
1337+
pass
1338+
"""
1339+
testdir.makepyfile(test_a=test_file, test_b=test_file)
1340+
result = testdir.runpytest("-n2", "--dist=loadgroup", "-v")
1341+
test_a_workers_and_test_count = get_workers_and_test_count_by_prefix(
1342+
"test_a.py::TestA", result.outlines
1343+
)
1344+
test_b_workers_and_test_count = get_workers_and_test_count_by_prefix(
1345+
"test_b.py::TestA", result.outlines
1346+
)
1347+
1348+
assert (
1349+
test_a_workers_and_test_count
1350+
in (
1351+
{"gw0": 5},
1352+
{"gw1": 0},
1353+
)
1354+
or test_a_workers_and_test_count in ({"gw0": 0}, {"gw1": 5})
1355+
)
1356+
assert (
1357+
test_b_workers_and_test_count
1358+
in (
1359+
{"gw0": 5},
1360+
{"gw1": 0},
1361+
)
1362+
or test_b_workers_and_test_count in ({"gw0": 0}, {"gw1": 5})
1363+
)
1364+
assert (
1365+
test_a_workers_and_test_count.items()
1366+
== test_b_workers_and_test_count.items()
1367+
)
1368+
1369+
def test_by_class(self, testdir):
1370+
testdir.makepyfile(
1371+
test_a="""
1372+
import pytest
1373+
class TestA:
1374+
@pytest.mark.xdist_group(name="xdist_group")
1375+
@pytest.mark.parametrize('i', range(10))
1376+
def test(self, i):
1377+
pass
1378+
class TestB:
1379+
@pytest.mark.xdist_group(name="xdist_group")
1380+
@pytest.mark.parametrize('i', range(10))
1381+
def test(self, i):
1382+
pass
1383+
"""
1384+
)
1385+
result = testdir.runpytest("-n2", "--dist=loadgroup", "-v")
1386+
test_a_workers_and_test_count = get_workers_and_test_count_by_prefix(
1387+
"test_a.py::TestA", result.outlines
1388+
)
1389+
test_b_workers_and_test_count = get_workers_and_test_count_by_prefix(
1390+
"test_a.py::TestB", result.outlines
1391+
)
1392+
1393+
assert (
1394+
test_a_workers_and_test_count
1395+
in (
1396+
{"gw0": 10},
1397+
{"gw1": 0},
1398+
)
1399+
or test_a_workers_and_test_count in ({"gw0": 0}, {"gw1": 10})
1400+
)
1401+
assert (
1402+
test_b_workers_and_test_count
1403+
in (
1404+
{"gw0": 10},
1405+
{"gw1": 0},
1406+
)
1407+
or test_b_workers_and_test_count in ({"gw0": 0}, {"gw1": 10})
1408+
)
1409+
assert (
1410+
test_a_workers_and_test_count.items()
1411+
== test_b_workers_and_test_count.items()
1412+
)
1413+
1414+
def test_module_single_start(self, testdir):
1415+
test_file1 = """
1416+
import pytest
1417+
@pytest.mark.xdist_group(name="xdist_group")
1418+
def test():
1419+
pass
1420+
"""
1421+
test_file2 = """
1422+
import pytest
1423+
def test_1():
1424+
pass
1425+
@pytest.mark.xdist_group(name="xdist_group")
1426+
def test_2():
1427+
pass
1428+
"""
1429+
testdir.makepyfile(test_a=test_file1, test_b=test_file1, test_c=test_file2)
1430+
result = testdir.runpytest("-n2", "--dist=loadgroup", "-v")
1431+
a = get_workers_and_test_count_by_prefix("test_a.py::test", result.outlines)
1432+
b = get_workers_and_test_count_by_prefix("test_b.py::test", result.outlines)
1433+
c = get_workers_and_test_count_by_prefix("test_c.py::test_2", result.outlines)
1434+
1435+
assert a.keys() == b.keys() and b.keys() == c.keys()
1436+
1437+
def test_with_two_group_names(self, testdir):
1438+
test_file = """
1439+
import pytest
1440+
@pytest.mark.xdist_group(name="group1")
1441+
def test_1():
1442+
pass
1443+
@pytest.mark.xdist_group("group2")
1444+
def test_2():
1445+
pass
1446+
"""
1447+
testdir.makepyfile(test_a=test_file, test_b=test_file)
1448+
result = testdir.runpytest("-n2", "--dist=loadgroup", "-v")
1449+
a_1 = get_workers_and_test_count_by_prefix("test_a.py::test_1", result.outlines)
1450+
a_2 = get_workers_and_test_count_by_prefix("test_a.py::test_2", result.outlines)
1451+
b_1 = get_workers_and_test_count_by_prefix("test_b.py::test_1", result.outlines)
1452+
b_2 = get_workers_and_test_count_by_prefix("test_b.py::test_2", result.outlines)
1453+
1454+
assert a_1.keys() == b_1.keys() and a_2.keys() == b_2.keys()
1455+
1456+
13291457
class TestLocking:
13301458
_test_content = """
13311459
class TestClassName%s(object):

0 commit comments

Comments
 (0)