From 66f937b90f0fe7e4d31598784e54307f64f72650 Mon Sep 17 00:00:00 2001 From: Jeffrey Meadows Date: Fri, 29 Aug 2014 13:59:21 -0700 Subject: [PATCH 1/4] Add option to split by class/module instead of by method/function. Add tests for this and a test to make sure all tests are picked up. Fix a few style issues. Remove broken test envs - hash_ring isn't py3 compatible, so neither is this, and conf.py is missing so docs don't work either. Add Travis CI integration. --- .travis.yml | 13 +++++++++ distributed_nose/__init__.py | 2 +- distributed_nose/plugin.py | 23 ++++++++++++++++ setup.py | 3 +-- tests/dummy_tests.py | 18 +++++++++++++ tests/test_distribution.py | 51 +++++++++++++++++++++++++++++++++++- tox.ini | 15 +++-------- 7 files changed, 110 insertions(+), 15 deletions(-) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..9424025 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: python +env: + - TOX_ENV=py26 + - TOX_ENV=py27 + - TOX_ENV=pypy + - TOX_ENV=nose-1-0 + - TOX_ENV=nose-1-3 +# commands to install dependencies +install: + - pip install tox --use-mirrors +# commands to run +script: + - tox -e $TOX_ENV diff --git a/distributed_nose/__init__.py b/distributed_nose/__init__.py index 4d9ed8f..21fada3 100644 --- a/distributed_nose/__init__.py +++ b/distributed_nose/__init__.py @@ -1,5 +1,5 @@ """Distribute your nose tests across multiple machines, hassle-free""" -VERSION = (0, 1, 2, '') +VERSION = (0, 1, 3, '') __version__ = '.'.join(map(str, VERSION[0:3])) + ''.join(VERSION[3:]) __author__ = 'Wes Winham' __contact__ = 'winhamwr@gmail.com' diff --git a/distributed_nose/plugin.py b/distributed_nose/plugin.py index ac65e93..caf8e72 100644 --- a/distributed_nose/plugin.py +++ b/distributed_nose/plugin.py @@ -8,6 +8,7 @@ logger = logging.getLogger('nose.plugins.distributed_nose') + class DistributedNose(Plugin): """ Distribute a test run, shared-nothing style, by specifying the total number @@ -103,9 +104,31 @@ def _options_are_valid(self): return True + def getCannotSplit(self, testObject): + obj_self = getattr(testObject, '__self__', None) + klass = None + + if obj_self is not None: + klass = getattr(testObject, '__class__', None) + else: + if hasattr(testObject, 'im_class'): + klass = testObject.im_class + + if klass is not None: + return not getattr(klass, '__distributed_can_split__', True) + + filepath, module, call = test_address(testObject) + return not getattr(module, '__distributed_can_split__', True) + def validateName(self, testObject): filepath, module, call = test_address(testObject) + # By default, we assume modules can be split, but if not, assign all tests in the module to one node + if self.getCannotSplit(testObject): + node = self.hash_ring.get_node(module) + if node != self.node_id: + return False + node = self.hash_ring.get_node('%s.%s' % (module, call)) if node != self.node_id: return False diff --git a/setup.py b/setup.py index 0858049..2b5282d 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,6 @@ 'Programming Language :: Python', 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.3', 'Operating System :: OS Independent', 'Operating System :: POSIX', 'Operating System :: Microsoft :: Windows', @@ -94,7 +93,7 @@ def add_doc(m): 'nose', 'hash_ring', ], - entry_points = { + entry_points={ 'nose.plugins.0.10': [ 'distributed = distributed_nose.plugin:DistributedNose', ], diff --git a/tests/dummy_tests.py b/tests/dummy_tests.py index 38b911f..eeeff7e 100644 --- a/tests/dummy_tests.py +++ b/tests/dummy_tests.py @@ -1,6 +1,7 @@ import unittest + class TC1(unittest.TestCase): def test_method1(self): assert True @@ -19,8 +20,25 @@ class TC2(TC1): pass +class TC3(unittest.TestCase): + __distributed_can_split__ = False + + def test_method1(self): + assert True + + def test_method2(self): + assert True + + def test_method3(self): + assert True + + def test_method4(self): + assert True + + def test_func1(): assert True + def test_func2(): assert True diff --git a/tests/test_distribution.py b/tests/test_distribution.py index 0b2d57f..d85622b 100644 --- a/tests/test_distribution.py +++ b/tests/test_distribution.py @@ -6,7 +6,8 @@ from distributed_nose.plugin import DistributedNose -from tests.dummy_tests import TC1, TC2, test_func1, test_func2 +from tests.dummy_tests import TC1, TC2, TC3, test_func1, test_func2 + class TestTestSelection(unittest.TestCase): @@ -46,3 +47,51 @@ def test_not_all_tests_found(self): self.assertFalse(all_allowed) + def test_all_tests_found(self): + plug1 = self.plugin + plug2 = DistributedNose() + + plug1.options(self.parser, env={}) + args = ['--nodes=2', '--node-number=1'] + options, _ = self.parser.parse_args(args) + plug1.configure(options, Config()) + + self.parser = OptionParser() + plug2.options(self.parser, env={}) + args = ['--nodes=2', '--node-number=2'] + options, _ = self.parser.parse_args(args) + plug2.configure(options, Config()) + + all_allowed = True + + for test in [TC1, TC2, TC3, test_func1, test_func2]: + if not (plug1.validateName(test) is None or plug2.validateName(test) is None): + all_allowed = False + + self.assertTrue(all_allowed) + + def test_can_distribute(self): + plug1 = self.plugin + plug2 = DistributedNose() + + plug1.options(self.parser, env={}) + args = ['--nodes=2', '--node-number=1'] + options, _ = self.parser.parse_args(args) + plug1.configure(options, Config()) + + self.parser = OptionParser() + plug2.options(self.parser, env={}) + args = ['--nodes=2', '--node-number=2'] + options, _ = self.parser.parse_args(args) + plug2.configure(options, Config()) + + any_allowed_1 = False + any_allowed_2 = False + + for test in [TC3.test_method1, TC3.test_method2, TC3.test_method3, TC3.test_method4]: + if plug1.validateName(test) is None: + any_allowed_1 = True + if plug2.validateName(test) is None: + any_allowed_2 = True + + self.assertTrue(any_allowed_1 ^ any_allowed_2) diff --git a/tox.ini b/tox.ini index 460b1e4..51a00c0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py26,py27,py33,pypy,nose-0-11,nose-1-0,docs +envlist = py26,py27,pypy,nose-1-0,nose-1-3 [testenv] deps = @@ -7,19 +7,12 @@ deps = commands = python setup.py nosetests -[testenv:nose-0-11] -basepython = python2.6 -deps = - nose<1.0 - [testenv:nose-1-0] basepython = python2.6 deps = nose>=1.0,<1.1 -[testenv:docs] -changedir = docs +[testenv:nose-1-3] +basepython = python2.6 deps = - sphinx -commands = - sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html + nose>=1.3,<1.4 From 0e808e85664c14423b8497de2ccc89f8b24dd8e1 Mon Sep 17 00:00:00 2001 From: Jeffrey Meadows Date: Fri, 29 Aug 2014 14:30:42 -0700 Subject: [PATCH 2/4] Actually split at class level if that's where the __distributed_can_split__ attribute is found. --- distributed_nose/plugin.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/distributed_nose/plugin.py b/distributed_nose/plugin.py index caf8e72..d749e98 100644 --- a/distributed_nose/plugin.py +++ b/distributed_nose/plugin.py @@ -115,17 +115,19 @@ def getCannotSplit(self, testObject): klass = testObject.im_class if klass is not None: - return not getattr(klass, '__distributed_can_split__', True) + if not getattr(klass, '__distributed_can_split__', True): + return klass filepath, module, call = test_address(testObject) - return not getattr(module, '__distributed_can_split__', True) + return module if not getattr(module, '__distributed_can_split__', True) else None def validateName(self, testObject): filepath, module, call = test_address(testObject) # By default, we assume modules can be split, but if not, assign all tests in the module to one node - if self.getCannotSplit(testObject): - node = self.hash_ring.get_node(module) + cannot_split = self.getCannotSplit(testObject) + if cannot_split: + node = self.hash_ring.get_node('%s' % cannot_split) if node != self.node_id: return False From b10b05311eecc6b90872fd8a85fffce21abbed83 Mon Sep 17 00:00:00 2001 From: Jeffrey Meadows Date: Tue, 2 Sep 2014 09:33:17 -0700 Subject: [PATCH 3/4] Add documentation for _distributed_can_split_, and change the flag name to a single underscore. --- README.md | 12 ++++++++++++ distributed_nose/plugin.py | 4 ++-- tests/dummy_tests.py | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5266bad..62cab2c 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,18 @@ Alternatively, you can use the environment variables: * `NOSE_NODES` * `NOSE_NODE_NUMBER` +### Specifying how tests are split + +By default, each test function or method can be run on a different machine. This, +however, is not always the best way to split tests; sometimes it's preferable to +keep tests in a certain class or module on the same machine. + +Simply specify + + _distributed_can_split_ = False + +in the class or module for which the containing tests should not be split. + ### Temporarily disabling test distribution In the case that you're using environment variables diff --git a/distributed_nose/plugin.py b/distributed_nose/plugin.py index d749e98..f0f1e20 100644 --- a/distributed_nose/plugin.py +++ b/distributed_nose/plugin.py @@ -115,11 +115,11 @@ def getCannotSplit(self, testObject): klass = testObject.im_class if klass is not None: - if not getattr(klass, '__distributed_can_split__', True): + if not getattr(klass, '_distributed_can_split_', True): return klass filepath, module, call = test_address(testObject) - return module if not getattr(module, '__distributed_can_split__', True) else None + return module if not getattr(module, '_distributed_can_split_', True) else None def validateName(self, testObject): filepath, module, call = test_address(testObject) diff --git a/tests/dummy_tests.py b/tests/dummy_tests.py index eeeff7e..140e5eb 100644 --- a/tests/dummy_tests.py +++ b/tests/dummy_tests.py @@ -21,7 +21,7 @@ class TC2(TC1): class TC3(unittest.TestCase): - __distributed_can_split__ = False + _distributed_can_split_ = False def test_method1(self): assert True From 79be7d2d5a6a66ee200dc1e045a8856bd827c002 Mon Sep 17 00:00:00 2001 From: Jeffrey Meadows Date: Thu, 4 Sep 2014 16:27:37 -0700 Subject: [PATCH 4/4] Include myself in AUTHORS. Update CHANGELOG and version number. Refactor how the splitting works and add a bit more documentation. --- AUTHORS | 1 + CHANGELOG.md | 7 +++++++ distributed_nose/__init__.py | 2 +- distributed_nose/plugin.py | 28 ++++++++++++++-------------- 4 files changed, 23 insertions(+), 15 deletions(-) diff --git a/AUTHORS b/AUTHORS index 2dc5869..8bc838e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1 +1,2 @@ Wes Winham +Jeff Meadows diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f02880..137ae92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ Changelog ========= +## 0.2.0 + +Test split granularity can now be controlled by setting _distributed_can_split_ +at the class or module level. Simply specify `_distributed_can_split = False` on +a module or on a class, and tests contained therein will be forced to run on the +same node. + ## 0.1.2 Test selection for Class-based tests no longer groups all methods from the same diff --git a/distributed_nose/__init__.py b/distributed_nose/__init__.py index 21fada3..45454ca 100644 --- a/distributed_nose/__init__.py +++ b/distributed_nose/__init__.py @@ -1,5 +1,5 @@ """Distribute your nose tests across multiple machines, hassle-free""" -VERSION = (0, 1, 3, '') +VERSION = (0, 2, 0, '') __version__ = '.'.join(map(str, VERSION[0:3])) + ''.join(VERSION[3:]) __author__ = 'Wes Winham' __contact__ = 'winhamwr@gmail.com' diff --git a/distributed_nose/plugin.py b/distributed_nose/plugin.py index f0f1e20..fb80627 100644 --- a/distributed_nose/plugin.py +++ b/distributed_nose/plugin.py @@ -104,7 +104,17 @@ def _options_are_valid(self): return True - def getCannotSplit(self, testObject): + def getLowestSplitLevelObject(self, testObject): + """ + Get an object name to hash for determining which node will run this test. + If the containing module cannot be split, return the module name. + If the module can be split, but the containing class cannot, return the module dot class name. + If the module and class can be split, return the module.test name. + """ + filepath, module, call = test_address(testObject) + if not getattr(module, '_distributed_can_split_', True): + return '%s' % module + obj_self = getattr(testObject, '__self__', None) klass = None @@ -116,22 +126,12 @@ def getCannotSplit(self, testObject): if klass is not None: if not getattr(klass, '_distributed_can_split_', True): - return klass + return '%s.%s' % (module, klass) - filepath, module, call = test_address(testObject) - return module if not getattr(module, '_distributed_can_split_', True) else None + return '%s.%s' % (module, call) def validateName(self, testObject): - filepath, module, call = test_address(testObject) - - # By default, we assume modules can be split, but if not, assign all tests in the module to one node - cannot_split = self.getCannotSplit(testObject) - if cannot_split: - node = self.hash_ring.get_node('%s' % cannot_split) - if node != self.node_id: - return False - - node = self.hash_ring.get_node('%s.%s' % (module, call)) + node = self.hash_ring.get_node(self.getLowestSplitLevelObject(testObject)) if node != self.node_id: return False