Merge pull request #242 from Corni/loadfilescope
Load-Distribute test cases by filename
This commit is contained in:
16
README.rst
16
README.rst
@@ -321,6 +321,22 @@ the same worker ``gw0``, while the test methods from classes ``TestHDF`` and
|
|||||||
Currently the groupings can't be customized, with grouping by class takes
|
Currently the groupings can't be customized, with grouping by class takes
|
||||||
priority over grouping by module.
|
priority over grouping by module.
|
||||||
|
|
||||||
|
Sending tests to the same worker based on their file
|
||||||
|
++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|
||||||
|
*New in version 1.21.*
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
This is an **experimental** feature: the actual functionality will
|
||||||
|
likely stay the same, but the CLI might change slightly in future versions.
|
||||||
|
|
||||||
|
You can send tests to the same worker grouped by their filename by using the
|
||||||
|
``--dist=loadfile`` option, so tests of the same file are guaranteed to run
|
||||||
|
in the same worker.
|
||||||
|
|
||||||
|
Using the example in the previous section, all tests from ``test_container.py`` will
|
||||||
|
run in the same worker, as well as the tests in ``test_io.py``.
|
||||||
|
|
||||||
|
|
||||||
Specifying "rsync" dirs in an ini-file
|
Specifying "rsync" dirs in an ini-file
|
||||||
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||||
|
|||||||
1
changelog/242.feature
Normal file
1
changelog/242.feature
Normal file
@@ -0,0 +1 @@
|
|||||||
|
New ``--dist=loadfile`` option which load-distributes test to workers grouped by the file the tests live in.
|
||||||
@@ -856,6 +856,61 @@ class TestLoadScope:
|
|||||||
'test_a.py::TestB', result.outlines) in ({'gw0': 10}, {'gw1': 10})
|
'test_a.py::TestB', result.outlines) in ({'gw0': 10}, {'gw1': 10})
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileScope:
|
||||||
|
|
||||||
|
def test_by_module(self, testdir):
|
||||||
|
test_file = """
|
||||||
|
import pytest
|
||||||
|
class TestA:
|
||||||
|
@pytest.mark.parametrize('i', range(10))
|
||||||
|
def test(self, i):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TestB:
|
||||||
|
@pytest.mark.parametrize('i', range(10))
|
||||||
|
def test(self, i):
|
||||||
|
pass
|
||||||
|
"""
|
||||||
|
testdir.makepyfile(
|
||||||
|
test_a=test_file,
|
||||||
|
test_b=test_file,
|
||||||
|
)
|
||||||
|
result = testdir.runpytest('-n2', '--dist=loadfile', '-v')
|
||||||
|
test_a_workers_and_test_count = get_workers_and_test_count_by_prefix(
|
||||||
|
'test_a.py::TestA', result.outlines)
|
||||||
|
test_b_workers_and_test_count = get_workers_and_test_count_by_prefix(
|
||||||
|
'test_b.py::TestB', result.outlines)
|
||||||
|
|
||||||
|
assert test_a_workers_and_test_count in ({'gw0': 10}, {'gw1': 0}) or \
|
||||||
|
test_a_workers_and_test_count in ({'gw0': 0}, {'gw1': 10})
|
||||||
|
assert test_b_workers_and_test_count in ({'gw0': 10}, {'gw1': 0}) or \
|
||||||
|
test_b_workers_and_test_count in ({'gw0': 0}, {'gw1': 10})
|
||||||
|
|
||||||
|
def test_by_class(self, testdir):
|
||||||
|
testdir.makepyfile(test_a="""
|
||||||
|
import pytest
|
||||||
|
class TestA:
|
||||||
|
@pytest.mark.parametrize('i', range(10))
|
||||||
|
def test(self, i):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class TestB:
|
||||||
|
@pytest.mark.parametrize('i', range(10))
|
||||||
|
def test(self, i):
|
||||||
|
pass
|
||||||
|
""")
|
||||||
|
result = testdir.runpytest('-n2', '--dist=loadfile', '-v')
|
||||||
|
test_a_workers_and_test_count = get_workers_and_test_count_by_prefix(
|
||||||
|
'test_a.py::TestA', result.outlines)
|
||||||
|
test_b_workers_and_test_count = get_workers_and_test_count_by_prefix(
|
||||||
|
'test_a.py::TestB', result.outlines)
|
||||||
|
|
||||||
|
assert test_a_workers_and_test_count in ({'gw0': 10}, {'gw1': 0}) or \
|
||||||
|
test_a_workers_and_test_count in ({'gw0': 0}, {'gw1': 10})
|
||||||
|
assert test_b_workers_and_test_count in ({'gw0': 10}, {'gw1': 0}) or \
|
||||||
|
test_b_workers_and_test_count in ({'gw0': 0}, {'gw1': 10})
|
||||||
|
|
||||||
|
|
||||||
def parse_tests_and_workers_from_output(lines):
|
def parse_tests_and_workers_from_output(lines):
|
||||||
result = []
|
result = []
|
||||||
for line in lines:
|
for line in lines:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from xdist.scheduler import (
|
|||||||
EachScheduling,
|
EachScheduling,
|
||||||
LoadScheduling,
|
LoadScheduling,
|
||||||
LoadScopeScheduling,
|
LoadScopeScheduling,
|
||||||
|
LoadFileScheduling,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -99,6 +100,7 @@ class DSession:
|
|||||||
'each': EachScheduling,
|
'each': EachScheduling,
|
||||||
'load': LoadScheduling,
|
'load': LoadScheduling,
|
||||||
'loadscope': LoadScopeScheduling,
|
'loadscope': LoadScopeScheduling,
|
||||||
|
'loadfile': LoadFileScheduling,
|
||||||
}
|
}
|
||||||
return schedulers[dist](config, log)
|
return schedulers[dist](config, log)
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ def pytest_addoption(parser):
|
|||||||
"when crashed (set to zero to disable this feature)")
|
"when crashed (set to zero to disable this feature)")
|
||||||
group.addoption(
|
group.addoption(
|
||||||
'--dist', metavar="distmode",
|
'--dist', metavar="distmode",
|
||||||
action="store", choices=['each', 'load', 'loadscope', 'no'],
|
action="store", choices=['each', 'load', 'loadscope', 'loadfile', 'no'],
|
||||||
dest="dist", default="no",
|
dest="dist", default="no",
|
||||||
help=("set mode for distributing tests to exec environments.\n\n"
|
help=("set mode for distributing tests to exec environments.\n\n"
|
||||||
"each: send each test to all available environments.\n\n"
|
"each: send each test to all available environments.\n\n"
|
||||||
@@ -39,6 +39,8 @@ def pytest_addoption(parser):
|
|||||||
" available environment.\n\n"
|
" available environment.\n\n"
|
||||||
"loadscope: load balance by sending pending groups of tests in"
|
"loadscope: load balance by sending pending groups of tests in"
|
||||||
" the same scope to any available environment.\n\n"
|
" the same scope to any available environment.\n\n"
|
||||||
|
"loadfile: load balance by sending test grouped by file"
|
||||||
|
" to any available environment.\n\n"
|
||||||
"(default) no: run tests inprocess, don't distribute."))
|
"(default) no: run tests inprocess, don't distribute."))
|
||||||
group.addoption(
|
group.addoption(
|
||||||
'--tx', dest="tx", action="append", default=[],
|
'--tx', dest="tx", action="append", default=[],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
from xdist.scheduler.each import EachScheduling # noqa
|
from xdist.scheduler.each import EachScheduling # noqa
|
||||||
from xdist.scheduler.load import LoadScheduling # noqa
|
from xdist.scheduler.load import LoadScheduling # noqa
|
||||||
from xdist.scheduler.loadscope import LoadScopeScheduling # noqa
|
from xdist.scheduler.loadscope import LoadScopeScheduling # noqa
|
||||||
|
from xdist.scheduler.filescope import LoadFileScheduling # noqa
|
||||||
|
|||||||
52
xdist/scheduler/filescope.py
Normal file
52
xdist/scheduler/filescope.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from . import LoadScopeScheduling
|
||||||
|
from py.log import Producer
|
||||||
|
|
||||||
|
|
||||||
|
class LoadFileScheduling(LoadScopeScheduling):
|
||||||
|
"""Implement load scheduling across nodes, but grouping test test file.
|
||||||
|
|
||||||
|
This distributes the tests collected across all nodes so each test is run
|
||||||
|
just once. All nodes collect and submit the list of tests and when all
|
||||||
|
collections are received it is verified they are identical collections.
|
||||||
|
Then the collection gets divided up in work units, grouped by test file,
|
||||||
|
and those work units get submitted to nodes. Whenever a node finishes an
|
||||||
|
item, it calls ``.mark_test_complete()`` which will trigger the scheduler
|
||||||
|
to assign more work units if the number of pending tests for the node falls
|
||||||
|
below a low-watermark.
|
||||||
|
|
||||||
|
When created, ``numnodes`` defines how many nodes are expected to submit a
|
||||||
|
collection. This is used to know when all nodes have finished collection.
|
||||||
|
|
||||||
|
This class behaves very much like LoadScopeScheduling, but with a file-level scope.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init(self, config, log=None):
|
||||||
|
super(LoadFileScheduling, self).__init__(config, log)
|
||||||
|
if log is None:
|
||||||
|
self.log = Producer('loadfilesched')
|
||||||
|
else:
|
||||||
|
self.log = log.loadfilesched
|
||||||
|
|
||||||
|
def _split_scope(self, nodeid):
|
||||||
|
"""Determine the scope (grouping) of a nodeid.
|
||||||
|
|
||||||
|
There are usually 3 cases for a nodeid::
|
||||||
|
|
||||||
|
example/loadsuite/test/test_beta.py::test_beta0
|
||||||
|
example/loadsuite/test/test_delta.py::Delta1::test_delta0
|
||||||
|
example/loadsuite/epsilon/__init__.py::epsilon.epsilon
|
||||||
|
|
||||||
|
#. Function in a test module.
|
||||||
|
#. Method of a class in a test module.
|
||||||
|
#. Doctest in a function in a package.
|
||||||
|
|
||||||
|
This function will group tests with the scope determined by splitting
|
||||||
|
the first ``::`` from the left. That is, test will be grouped in a
|
||||||
|
single work unit when they reside in the same file.
|
||||||
|
In the above example, scopes will be::
|
||||||
|
|
||||||
|
example/loadsuite/test/test_beta.py
|
||||||
|
example/loadsuite/test/test_delta.py
|
||||||
|
example/loadsuite/epsilon/__init__.py
|
||||||
|
"""
|
||||||
|
return nodeid.split('::', 1)[0]
|
||||||
@@ -368,12 +368,12 @@ class LoadScopeScheduling:
|
|||||||
extra_nodes = len(self.nodes) - len(self.workqueue)
|
extra_nodes = len(self.nodes) - len(self.workqueue)
|
||||||
|
|
||||||
if extra_nodes > 0:
|
if extra_nodes > 0:
|
||||||
self.log('Shuting down {} nodes'.format(extra_nodes))
|
self.log('Shuting down {0} nodes'.format(extra_nodes))
|
||||||
|
|
||||||
for _ in range(extra_nodes):
|
for _ in range(extra_nodes):
|
||||||
unused_node, assigned = self.assigned_work.popitem(last=True)
|
unused_node, assigned = self.assigned_work.popitem(last=True)
|
||||||
|
|
||||||
self.log('Shuting down unused node {}'.format(unused_node))
|
self.log('Shuting down unused node {0}'.format(unused_node))
|
||||||
unused_node.shutdown()
|
unused_node.shutdown()
|
||||||
|
|
||||||
# Assign initial workload
|
# Assign initial workload
|
||||||
|
|||||||
Reference in New Issue
Block a user