summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--NEWS12
-rw-r--r--doc/MANUAL.txt29
-rw-r--r--testrepository/commands/load.py35
-rw-r--r--testrepository/repository/memory.py5
-rw-r--r--testrepository/results.py2
-rw-r--r--testrepository/setuptools_command.py2
-rw-r--r--testrepository/testcommand.py156
-rw-r--r--testrepository/tests/commands/test_load.py14
-rw-r--r--testrepository/tests/commands/test_run.py1
-rw-r--r--testrepository/tests/test_testcommand.py32
-rw-r--r--testrepository/tests/ui/test_cli.py2
11 files changed, 163 insertions, 127 deletions
diff --git a/NEWS b/NEWS
index c38a1a4..16d5498 100644
--- a/NEWS
+++ b/NEWS
@@ -8,6 +8,12 @@ NEXT (In development)
CHANGES
-------
+* A new testr.conf option ``group_regex`` can be used for grouping
+ tests so that they get run in the same backend runner. (Matthew Treinish)
+
+* Fix Python 3.* support for entrypoints; the initial code was Python3
+ incompatible. (Robert Collins, Clark Boylan, #1187192)
+
* Switch to using multiprocessing to determine CPU counts.
(Chris Jones, #1092276)
@@ -16,8 +22,10 @@ CHANGES
load command to take interactive input without it reading from the raw
subunit stream on stdin. (Robert Collins)
-* Add a new testr.conf option, group_regex, that can be used for grouping
- test_ids with a regular expression. (Matthew Treinish)
+* The scheduler can now groups tests together permitting co-dependent tests to
+ always be scheduled onto the same backend. Note that this does not force
+ co-dependent tests to be executed, so partial test runs (e.g. --failing)
+ may still fail. (Matthew Treinish, Robert Collins)
0.0.15
++++++
diff --git a/doc/MANUAL.txt b/doc/MANUAL.txt
index d06dd3d..788f93c 100644
--- a/doc/MANUAL.txt
+++ b/doc/MANUAL.txt
@@ -205,28 +205,23 @@ And then find tests with that tag::
$ testr last --subunit | subunit-filter -s --xfail --with-tag=worker-3 | subunit-ls > slave-3.list
-Test Grouping
-~~~~~~~~~~~~~
+Grouping Tests
+~~~~~~~~~~~~~~
In certain scenarios you may want to group tests of a certain type together
-so that they will run together. The group_regex option allows you do this by
-passing in a regex string in as an option in .testr.conf. The test scheduler
-will group the tests based on the result of matching the regex to the test id.
-Then when the scheduler is partitioning the tests these groups will be
-scheduled as a single unit. In other words each group will be run on the same
-partition.
-
-The group_regex to use can be set in .testr.conf with the group_regex option.
-If it is necessary to have the regex string for use elsewhere in .testr.conf
-then it can be accessed using the $GROUP_REGEX variable.
+so that they will be run by the same backend. The group_regex option in
+.testr.conf permits this. When set, tests are grouped by the group(0) of any
+regex match. Tests with no match are not grouped.
For example, extending the python sample .testr.conf from the configuration
-section with a group regex that will group python tests cases together::
+section with a group regex that will group python tests cases together by
+class (the last . splits the class and test method)::
- [DEFAULT]
- test_command=foo $IDOPTION
- test_id_option=--bar $IDFILE
- group_regex=([^\.]*\.)*
+ [DEFAULT]
+ test_command=python -m subunit.run discover . $LISTOPT $IDOPTION
+ test_id_option=--load-list $IDFILE
+ test_list_option=--list
+ group_regex=([^\.]+\.)+
Remote or isolated test environments
diff --git a/testrepository/commands/load.py b/testrepository/commands/load.py
index 96d15f5..0ca4688 100644
--- a/testrepository/commands/load.py
+++ b/testrepository/commands/load.py
@@ -17,6 +17,7 @@
from functools import partial
from operator import methodcaller
import optparse
+import threading
from extras import try_import
v2_avail = try_import('subunit.ByteStreamToStreamResult')
@@ -29,6 +30,26 @@ from testrepository.commands import Command
from testrepository.repository import RepositoryNotFound
from testrepository.testcommand import TestCommand
+class InputToStreamResult(object):
+ """Generate Stream events from stdin.
+
+ Really a UI responsibility?
+ """
+
+ def __init__(self, stream):
+ self.source = stream
+ self.stop = False
+
+ def run(self, result):
+ while True:
+ if self.stop:
+ return
+ char = self.source.read(1)
+ if not char:
+ return
+ if char == b'a':
+ result.status(test_id='stdin', test_status='fail')
+
class load(Command):
"""Load a subunit stream into a repository.
@@ -39,7 +60,7 @@ class load(Command):
Unless the stream is a partial stream, any existing failures are discarded.
"""
- input_streams = ['subunit+']
+ input_streams = ['subunit+', 'interactive?']
args = [ExistingPathArgument('streams', min=0, max=None)]
options = [
@@ -116,11 +137,23 @@ class load(Command):
output_result, summary_result = self.ui.make_result(
inserter.get_id, testcommand, previous_run=previous_run)
result = testtools.CopyStreamResult([inserter, output_result])
+ runner_thread = None
result.startTestRun()
try:
+ # Convert user input into a stdin event stream
+ interactive_streams = list(self.ui.iter_streams('interactive'))
+ if interactive_streams:
+ case = InputToStreamResult(interactive_streams[0])
+ runner_thread = threading.Thread(
+ target=case.run, args=(result,))
+ runner_thread.daemon = True
+ runner_thread.start()
case.run(result)
finally:
result.stopTestRun()
+ if interactive_streams and runner_thread:
+ runner_thread.stop = True
+ runner_thread.join(10)
if not summary_result.wasSuccessful():
return 1
else:
diff --git a/testrepository/repository/memory.py b/testrepository/repository/memory.py
index 8aee71f..07eb667 100644
--- a/testrepository/repository/memory.py
+++ b/testrepository/repository/memory.py
@@ -14,6 +14,7 @@
"""In memory storage of test results."""
+from collections import OrderedDict
from io import BytesIO
from operator import methodcaller
@@ -55,7 +56,7 @@ class Repository(AbstractRepository):
def __init__(self):
# Test runs:
self._runs = []
- self._failing = {} # id -> test
+ self._failing = OrderedDict() # id -> test
self._times = {} # id -> duration
def count(self):
@@ -157,7 +158,7 @@ class _Inserter(AbstractTestRun):
self._repository._runs.append(self)
self._run_id = len(self._repository._runs) - 1
if not self._partial:
- self._repository._failing = {}
+ self._repository._failing = OrderedDict()
for test_dict in self._tests:
test_id = test_dict['id']
if test_dict['status'] == 'fail':
diff --git a/testrepository/results.py b/testrepository/results.py
index 8938314..ed01856 100644
--- a/testrepository/results.py
+++ b/testrepository/results.py
@@ -29,7 +29,7 @@ class SummarizingResult(StreamSummary):
self._last_time = None
def status(self, *args, **kwargs):
- if 'timestamp' in kwargs:
+ if kwargs.get('timestamp') is not None:
timestamp = kwargs['timestamp']
if self._last_time is None:
self._first_time = timestamp
diff --git a/testrepository/setuptools_command.py b/testrepository/setuptools_command.py
index 2718e3e..fbaf606 100644
--- a/testrepository/setuptools_command.py
+++ b/testrepository/setuptools_command.py
@@ -77,7 +77,7 @@ class Testr(cmd.Command):
raise distutils.errors.DistutilsError(
"testr failed (%d)" % testr_ret)
if self.slowest:
- print "Slowest Tests"
+ print ("Slowest Tests")
self._run_testr("slowest")
if self.coverage:
self._coverage_after()
diff --git a/testrepository/testcommand.py b/testrepository/testcommand.py
index 9fc9ad0..2cb4d50 100644
--- a/testrepository/testcommand.py
+++ b/testrepository/testcommand.py
@@ -16,6 +16,7 @@
from extras import try_imports
+from collections import defaultdict
ConfigParser = try_imports(['ConfigParser', 'configparser'])
import itertools
import operator
@@ -72,8 +73,7 @@ testrconf_help = dedent("""
be adjusted if the paths are synched with different names.
* instance_dispose -- dispose of one or more test running environments.
Accepts $INSTANCE_IDS.
- * group_regex -- The optional variable to set a regex string to be used
- for grouping test ids.
+ * group_regex -- If set group tests by the matched section of the test id.
* $IDOPTION -- the variable to use to trigger running some specific tests.
* $IDFILE -- A file created before the test command is run and deleted
afterwards which contains a list of test ids, one per line. This can
@@ -81,8 +81,6 @@ testrconf_help = dedent("""
* $IDLIST -- A list of the test ids to run, separated by spaces. IDLIST
defaults to an empty string when no test ids are known and no explicit
default is provided. This will not handle test ids with spaces.
- * $GROUP_REGEX -- The variable for the regex string used for grouping
- tests.
See the testrepository manual for example .testr.conf files in different
programming languages.
@@ -138,7 +136,7 @@ class TestListingFixture(Fixture):
def __init__(self, test_ids, cmd_template, listopt, idoption, ui,
repository, parallel=True, listpath=None, parser=None,
- test_filters=None, instance_source=None, group_regex=None):
+ test_filters=None, instance_source=None, group_callback=None):
"""Create a TestListingFixture.
:param test_ids: The test_ids to use. May be None indicating that
@@ -171,8 +169,10 @@ class TestListingFixture(Fixture):
:param instance_source: A source of test run instances. Must support
obtain_instance(max_concurrency) -> id and release_instance(id)
calls.
- :param group_regex: An optional regular expression string which is used
- to provide a grouping hint to the test partitioner
+ :param group_callback: If supplied, should be a function that accepts a
+ test id and returns a group id. A group id is an arbitrary value
+ used as a dictionary key in the scheduler. All test ids with the
+ same group id are scheduled onto the same backend test process.
"""
self.test_ids = test_ids
self.template = cmd_template
@@ -184,16 +184,14 @@ class TestListingFixture(Fixture):
self._listpath = listpath
self._parser = parser
self.test_filters = test_filters
+ self._group_callback = group_callback
self._instance_source = instance_source
- self.group_regex = group_regex
def setUp(self):
super(TestListingFixture, self).setUp()
- variable_regex = '\$(IDOPTION|IDFILE|IDLIST|LISTOPT|GROUP_REGEX)'
+ variable_regex = '\$(IDOPTION|IDFILE|IDLIST|LISTOPT)'
variables = {}
list_variables = {'LISTOPT': self.listopt}
- if self.group_regex:
- variables['GROUP_REGEX'] = self.group_regex
cmd = self.template
try:
default_idstr = self._parser.get('DEFAULT', 'test_id_list_default')
@@ -336,7 +334,6 @@ class TestListingFixture(Fixture):
:return: A list of spawned processes.
"""
result = []
- group_tags = None
test_ids = self.test_ids
if self.concurrency == 1 and (test_ids is None or test_ids):
# Have to customise cmd here, as instances are allocated
@@ -353,11 +350,8 @@ class TestListingFixture(Fixture):
return [CallWhenProcFinishes(run_proc,
lambda:self._instance_source.release_instance(instance))]
else:
- return [run_proc]
- if self.group_regex:
- group_tags = self.filter_test_groups(test_ids, self.group_regex)
- test_id_groups = self.partition_tests(test_ids, self.concurrency,
- group_tags)
+ return [run_proc]
+ test_id_groups = self.partition_tests(test_ids, self.concurrency)
for test_ids in test_id_groups:
if not test_ids:
# No tests in this partition
@@ -369,28 +363,7 @@ class TestListingFixture(Fixture):
result.extend(fixture.run_tests())
return result
- def filter_test_groups(self, test_ids, group_regex):
- """Add a group tag based on the regex provided
-
- :return A dict with the group tags as keys and a list of
- test ids that are a member of the group tag as the value
- """
-
- group_dict = {}
- expr = re.compile(group_regex)
- for test_id in test_ids:
- match = expr.match(test_id)
- if match:
- group_id = match.group(0)
- else:
- group_id = None
- if group_dict.get(group_id):
- group_dict[group_id].append(test_id)
- else:
- group_dict[group_id] = [test_id]
- return group_dict
-
- def partition_tests(self, test_ids, concurrency, group_tags=None):
+ def partition_tests(self, test_ids, concurrency):
"""Parition test_ids by concurrency.
Test durations from the repository are used to get partitions which
@@ -401,63 +374,59 @@ class TestListingFixture(Fixture):
:return: A list where each element is a distinct subset of test_ids,
and the union of all the elements is equal to set(test_ids).
"""
-
partitions = [list() for i in range(concurrency)]
timed_partitions = [[0.0, partition] for partition in partitions]
time_data = self.repository.get_test_times(test_ids)
- timed = time_data['known']
- unknown = time_data['unknown']
- # Schedule test groups by the sum of execute time for each test that is
- # a member of the group
- if group_tags:
- group_timed = {}
- group_unknown = []
- for group_tag in group_tags.keys():
- time = 0.0
- for test_id in group_tags[group_tag]:
- # If a test_id is not timed remove the whole group from the
- # timed groups dict and
- if test_id in unknown:
- if group_tag in group_timed.keys():
- group_timed.pop(group_tag, None)
- group_unknown.append(group_tag)
- break
- time = time + timed[test_id]
- group_timed[group_tag] = (group_tags[group_tag], time)
-
- queue = sorted(group_timed.items(),
- key=operator.itemgetter(1),
- reverse=True)
-
- # Sort the tests by runtime
- for group_tag, test_tuple in queue:
- test_ids = test_tuple[0]
- duration = test_tuple[1]
- timed_partitions[0][0] = timed_partitions[0][0] + duration
- # Handle groups larger than a single entry
- timed_partitions[0][1].extend(test_ids)
- timed_partitions.sort(key=lambda item: (item[0], len(item[1])))
- for partition, group_id in zip(itertools.cycle(partitions),
- group_unknown):
- partition = partition + group_tags[group_id]
- return partitions
-
+ timed_tests = time_data['known']
+ unknown_tests = time_data['unknown']
+ # Group tests: generate group_id -> test_ids.
+ group_ids = defaultdict(list)
+ if self._group_callback is None:
+ group_callback = lambda _:None
+ else:
+ group_callback = self._group_callback
+ for test_id in test_ids:
+ group_id = group_callback(test_id) or test_id
+ group_ids[group_id].append(test_id)
+ # Time groups: generate three sets of groups:
+ # - fully timed dict(group_id -> time),
+ # - partially timed dict(group_id -> time) and
+ # - unknown (set of group_id)
+ # We may in future treat partially timed different for scheduling, but
+ # at least today we just schedule them after the fully timed groups.
+ timed = {}
+ partial = {}
+ unknown = []
+ for group_id, group_tests in group_ids.items():
+ untimed_ids = unknown_tests.intersection(group_tests)
+ group_time = sum([timed_tests[test_id]
+ for test_id in untimed_ids.symmetric_difference(group_tests)])
+ if not untimed_ids:
+ timed[group_id] = group_time
+ elif group_time:
+ partial[group_id] = group_time
+ else:
+ unknown.append(group_id)
# Scheduling is NP complete in general, so we avoid aiming for
# perfection. A quick approximation that is sufficient for our general
# needs:
- # sort the tests by time
- # allocate to partitions by putting each test in to the partition with
- # the current (lowest time, shortest length)
- else:
- queue = sorted(timed.items(), key=operator.itemgetter(1), reverse=True)
- for test_id, duration in queue:
+ # sort the groups by time
+ # allocate to partitions by putting each group in to the partition with
+ # the current (lowest time, shortest length[in tests])
+ def consume_queue(groups):
+ queue = sorted(
+ groups.items(), key=operator.itemgetter(1), reverse=True)
+ for group_id, duration in queue:
timed_partitions[0][0] = timed_partitions[0][0] + duration
- timed_partitions[0][1].append(test_id)
+ timed_partitions[0][1].extend(group_ids[group_id])
timed_partitions.sort(key=lambda item:(item[0], len(item[1])))
- # Assign tests with unknown times in round robin fashion to the partitions.
- for partition, test_id in zip(itertools.cycle(partitions), unknown):
- partition.append(test_id)
- return partitions
+ consume_queue(timed)
+ consume_queue(partial)
+ # Assign groups with entirely unknown times in round robin fashion to
+ # the partitions.
+ for partition, group_id in zip(itertools.cycle(partitions), unknown):
+ partition.extend(group_ids[group_id])
+ return partitions
def callout_concurrency(self):
"""Callout for user defined concurrency."""
@@ -587,17 +556,24 @@ class TestCommand(Fixture):
group_regex = parser.get('DEFAULT', 'group_regex')
except ConfigParser.NoOptionError:
group_regex = None
+ if group_regex:
+ def group_callback(test_id, regex=re.compile(group_regex)):
+ match = regex.match(test_id)
+ if match:
+ return match.group(0)
+ else:
+ group_callback = None
if self.oldschool:
listpath = os.path.join(self.ui.here, 'failing.list')
result = self.run_factory(test_ids, cmd, listopt, idoption,
self.ui, self.repository, listpath=listpath, parser=parser,
test_filters=test_filters, instance_source=self,
- group_regex=group_regex)
+ group_callback=group_callback)
else:
result = self.run_factory(test_ids, cmd, listopt, idoption,
self.ui, self.repository, parser=parser,
test_filters=test_filters, instance_source=self,
- group_regex=group_regex)
+ group_callback=group_callback)
return result
def get_filter_tags(self):
diff --git a/testrepository/tests/commands/test_load.py b/testrepository/tests/commands/test_load.py
index ed564c8..11c42d7 100644
--- a/testrepository/tests/commands/test_load.py
+++ b/testrepository/tests/commands/test_load.py
@@ -224,6 +224,20 @@ class TestCommandLoad(ResourcedTestCase):
self.assertEqual(0, cmd.execute())
self.assertEqual([], ui.outputs)
+ def test_load_abort_over_interactive_stream(self):
+ ui = UI([('subunit', b''), ('interactive', b'a\n')])
+ cmd = load.load(ui)
+ ui.set_command(cmd)
+ cmd.repository_factory = memory.RepositoryFactory()
+ cmd.repository_factory.initialise(ui.here)
+ ret = cmd.execute()
+ self.assertEqual(
+ [('results', Wildcard),
+ ('summary', False, 1, None, None, None,
+ [('id', 0, None), ('failures', 1, None)])],
+ ui.outputs)
+ self.assertEqual(1, ret)
+
def test_partial_passed_to_repo(self):
ui = UI([('subunit', _b(''))], [('quiet', True), ('partial', True)])
cmd = load.load(ui)
diff --git a/testrepository/tests/commands/test_run.py b/testrepository/tests/commands/test_run.py
index 41a05ad..1efc5df 100644
--- a/testrepository/tests/commands/test_run.py
+++ b/testrepository/tests/commands/test_run.py
@@ -366,7 +366,6 @@ class TestCommand(ResourcedTestCase):
def test_regex_test_filter_with_explicit_ids(self):
ui, cmd = self.get_test_ui_and_cmd(
args=('g1', '--', 'bar', 'quux'),options=[('failing', True)])
- ui.proc_outputs = ['ab-cd\nefgh\n']
cmd.repository_factory = memory.RepositoryFactory()
self.setup_repo(cmd, ui)
self.set_config(
diff --git a/testrepository/tests/test_testcommand.py b/testrepository/tests/test_testcommand.py
index ef71df0..605d490 100644
--- a/testrepository/tests/test_testcommand.py
+++ b/testrepository/tests/test_testcommand.py
@@ -226,9 +226,10 @@ class TestTestCommand(ResourcedTestCase):
self.set_config(
'[DEFAULT]\ntest_command=foo $IDOPTION\n'
'test_id_option=--load-list $IDFILE\n'
- 'group_regex=test\n')
+ 'group_regex=([^\\.]+\\.)+\n')
fixture = self.useFixture(command.get_run_command())
- self.assertEqual('test', fixture.group_regex)
+ self.assertEqual(
+ 'pkg.class.', fixture._group_callback('pkg.class.test_method'))
def test_extra_args_passed_in(self):
ui, command = self.get_test_ui_and_cmd()
@@ -371,7 +372,7 @@ class TestTestCommand(ResourcedTestCase):
self.assertEqual(1, len(partitions[0]))
self.assertEqual(1, len(partitions[1]))
- def test_partition_tests_with_group_regex(self):
+ def test_partition_tests_with_grouping(self):
repo = memory.RepositoryFactory().initialise('memory:')
result = repo.get_inserter()
result.startTestRun()
@@ -390,17 +391,26 @@ class TestTestCommand(ResourcedTestCase):
'TestCase2.fast2', 'TestCase4.test',
'testdir.testfile.TestCase5.test'])
regex = 'TestCase[0-5]'
- group_tags = fixture.filter_test_groups(test_ids, regex)
- partitions = fixture.partition_tests(test_ids, 2, group_tags)
+ def group_id(test_id, regex=re.compile('TestCase[0-5]')):
+ match = regex.match(test_id)
+ if match:
+ return match.group(0)
+ # There isn't a public way to define a group callback [as yet].
+ fixture._group_callback = group_id
+ partitions = fixture.partition_tests(test_ids, 2)
+ # Timed groups are deterministic:
+ self.assertTrue('TestCase2.fast1' in partitions[0])
+ self.assertTrue('TestCase2.fast2' in partitions[0])
self.assertTrue('TestCase1.slow' in partitions[1])
self.assertTrue('TestCase1.fast' in partitions[1])
self.assertTrue('TestCase1.fast2' in partitions[1])
- self.assertTrue('TestCase3.test2' in partitions[1])
- self.assertTrue('TestCase3.test1' in partitions[1])
- self.assertTrue('TestCase4.test' in partitions[1])
- self.assertTrue('testdir.testfile.TestCase5.test' in partitions[0])
- self.assertTrue('TestCase2.fast1' in partitions[0])
- self.assertTrue('TestCase2.fast2' in partitions[0])
+ # Untimed groups just need to be kept together:
+ if 'TestCase3.test1' in partitions[0]:
+ self.assertTrue('TestCase3.test2' in partitions[0])
+ if 'TestCase4.test' not in partitions[0]:
+ self.assertTrue('TestCase4.test' in partitions[1])
+ if 'testdir.testfile.TestCase5.test' not in partitions[0]:
+ self.assertTrue('testdir.testfile.TestCase5.test' in partitions[1])
def test_run_tests_with_instances(self):
# when there are instances and no instance_execute, run_tests acts as
diff --git a/testrepository/tests/ui/test_cli.py b/testrepository/tests/ui/test_cli.py
index e75caa7..4935fa0 100644
--- a/testrepository/tests/ui/test_cli.py
+++ b/testrepository/tests/ui/test_cli.py
@@ -157,7 +157,7 @@ FAIL: testrepository.tests.ui.test_cli.Case.method
----------------------------------------------------------------------
...Traceback (most recent call last):...
File "...test_cli.py", line ..., in method
- self.fail(\'quux\')
+ self.fail(\'quux\')...
AssertionError: quux...
""", doctest.ELLIPSIS))