Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to split by class/module + cleanup #4

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion distributed_nose/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Distribute your nose tests across multiple machines, hassle-free"""
VERSION = (0, 1, 2, '')
VERSION = (0, 1, 3, '')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're changing the behavior of class-based splitting here, right? in 1.2., two methods on the same TestCase will always end up on the same node. With this change, they won't.

Personally, my primary use case is to run a bunch of Django test cases. For those, I want to keep classes together because I use fixtures and running two tests with the same fixtures is faster because we do a transaction rollback. Updating all of my test cases to include _distributed_can_split_ = False would be pretty rough.

How do you feel about leaving the current default behavior where tests are not split across classes?

Either way, I think the new version should be 0.2.0 since we're adding a new feature.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another possibility would be adding a command line argument to change the default split behavior. I'd generally rather avoid that, though, if we can come to a consensus.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed with bumping the version to 0.2.0.

My primary case also involves wanting to keep classes together, but I don't think the current code necessarily does that. Lucky for me, all my test classes already derive from my own subclass of TestCase, so I just put _distributed_can_split_ = False in the base class.

I ran:

$ nosetests tests/dummy_tests.py --collect-only --verbose --nodes 3 --node-number 1
$ nosetests tests/dummy_tests.py --collect-only --verbose --nodes 3 --node-number 2
$ nosetests tests/dummy_tests.py --collect-only --verbose --nodes 3 --node-number 3

And I see tests from TC1 and from TC2 being split among the nodes.

$ nosetests tests/dummy_tests.py --collect-only --verbose --nodes 3 --node-number 1

test_method1 (tests.dummy_tests.TC1) ... ok
test_method4 (tests.dummy_tests.TC1) ... ok
test_method1 (tests.dummy_tests.TC2) ... ok
tests.dummy_tests.test_func2 ... ok

----------------------------------------------------------------------
Ran 4 tests in 0.000s

Anyway, that's why I coded it up that way, to keep it from being a breaking change.

__version__ = '.'.join(map(str, VERSION[0:3])) + ''.join(VERSION[3:])
__author__ = 'Wes Winham'
__contact__ = '[email protected]'
Expand Down
25 changes: 25 additions & 0 deletions distributed_nose/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -103,9 +104,33 @@ def _options_are_valid(self):

return True

def getCannotSplit(self, testObject):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So it looks like what we're actually returning here is returning either the class or the module if we can't split it, right?

I think we could simplify this code and reduce the duplication inside validateName by making this method instead always return a string representing the lowest level at which this method/class/module can be split. So for things that can be split, we'd return '%s.%s' % (module, call). For a module that can't be split, we'd return '%s' % module. For a class that can't be split, '%s' % klass

Maybe this could be called getLowestSplitLevelHash? It should also probably include a quick docstring to this effect.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion and I even uncovered some bugs/weird behaviors while refactoring.

This code will split at the class level even if the module specifies no splitting, but that's fixed in the next commit.

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:
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

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))
if node != self.node_id:
return False
Expand Down
3 changes: 1 addition & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -94,7 +93,7 @@ def add_doc(m):
'nose',
'hash_ring',
],
entry_points = {
entry_points={
'nose.plugins.0.10': [
'distributed = distributed_nose.plugin:DistributedNose',
],
Expand Down
18 changes: 18 additions & 0 deletions tests/dummy_tests.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@

import unittest


class TC1(unittest.TestCase):
def test_method1(self):
assert True
Expand All @@ -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
51 changes: 50 additions & 1 deletion tests/test_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand Down Expand Up @@ -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)
15 changes: 4 additions & 11 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -1,25 +1,18 @@
[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 =
nose>=1.2.1,<1.3
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