From ee3696dc2f8733750408ab30a6083031eb51d6eb Mon Sep 17 00:00:00 2001 From: Thomas W Date: Thu, 10 Apr 2014 15:42:01 -0700 Subject: [PATCH 01/37] Configuration module refactor This makes testing the settings module stuff easier and eliminates the need for any path manipulation. --- testtube/conf.py | 77 +++++++++++++++------------------- testtube/core.py | 8 ++-- testtube/decorators.py | 5 ++- testtube/helpers.py | 30 ++++++------- testtube/runner.py | 6 +-- testtube/tests/test_conf.py | 24 +++++------ testtube/tests/test_helpers.py | 9 ++-- testtube/tests/test_runner.py | 5 ++- 8 files changed, 78 insertions(+), 86 deletions(-) diff --git a/testtube/conf.py b/testtube/conf.py index b0628c2..ad908c6 100644 --- a/testtube/conf.py +++ b/testtube/conf.py @@ -1,18 +1,40 @@ """testtube settings module.""" import argparse +import imp import os -import sys +import types -# testube settings -SRC_DIR = None -PATTERNS = () +class Settings(types.ModuleType): + # testube default settings + CWD_SRC_DIR = '' + SRC_DIR = os.getcwd() + PATTERNS = () + + @classmethod + def configure(cls, src_dir, settings): + """Configures testtube to use a src directory and settings module.""" + cls.CWD_SRC_DIR = src_dir + cls.SRC_DIR = os.path.realpath( + os.path.join(os.getcwd(), cls.CWD_SRC_DIR)) + cls.get_settings(settings) + + @classmethod + def get_settings(cls, settings_module): + """Set conf attributes equal to all uppercase attributes of settings""" + settings = imp.load_source( + 'settings', os.path.join(os.getcwd(), settings_module)) + + cls.PATTERNS = settings.PATTERNS + + @classmethod + def short_path(cls, path): + """Removes conf.SRC_DIR from a given path.""" + return path.partition("%s%s" % (cls.SRC_DIR, '/'))[2] -def get_arguments(): - """Prompts the user for a source directory and an optional settings - module. - """ +def get_arguments(): + """Prompts user for a source directory and an optional settings module.""" parser = argparse.ArgumentParser( description='Watch a directory and run a custom set of tests whenever' ' a file changes.') @@ -20,41 +42,10 @@ def get_arguments(): '--src_dir', type=str, default=os.getcwd(), help='The directory to watch for changes. (Defaults to CWD)') parser.add_argument( - '--settings', type=str, default='tube', - help='The testtube settings module that defines which tests to run.' - ' (Defaults to "tube" - your settings module must be importable' - ' from your current working directory)') + '--settings', type=str, default='tube.py', + help='Path to a testtube settings file that defines which tests to run' + ' (Defaults to "tube.py" - your settings file must be importable' + ' and the path must be relative to your CWD)') args = parser.parse_args() return args.src_dir, args.settings - - -def short_path(path): - """Remove conf.SRC_DIR from a given path.""" - return path.partition("%s%s" % (SRC_DIR, '/'))[2] - - -def configure(src_dir, settings): - """Configure the app to use the specified SRC_DIR and extract the - relevant settings from the specified settings module. - - """ - _set_src_dir(src_dir) - _get_test_suite_from_settings(settings) - - -def _set_src_dir(src_dir): - """Generate an absolute path by merging the cwd with the passed src dir""" - global SRC_DIR - - SRC_DIR = os.path.realpath(os.path.join(os.getcwd(), src_dir)) - - -def _get_test_suite_from_settings(settings_module): - """Import settings_module and extract the relevant settings.""" - global PATTERNS - - sys.path.append(os.getcwd()) - settings = __import__(settings_module) - - PATTERNS = settings.PATTERNS diff --git a/testtube/core.py b/testtube/core.py index bf67b61..f74c0ac 100644 --- a/testtube/core.py +++ b/testtube/core.py @@ -3,19 +3,19 @@ from watchdog.observers import Observer -from testtube import conf +from testtube.conf import get_arguments, Settings from testtube.handlers import PyChangeHandler def main(): # Configure the app based on passed arguments - conf.configure(*conf.get_arguments()) + Settings.configure(*get_arguments()) observer = Observer() - observer.schedule(PyChangeHandler(), conf.SRC_DIR, recursive=True) + observer.schedule(PyChangeHandler(), Settings.SRC_DIR, recursive=True) observer.start() - print('testtube is now watching %s for changes...\n' % conf.SRC_DIR) + print('testtube is now watching %s for changes...\n' % Settings.SRC_DIR) try: while True: diff --git a/testtube/decorators.py b/testtube/decorators.py index 5506b4a..020ee75 100644 --- a/testtube/decorators.py +++ b/testtube/decorators.py @@ -1,3 +1,6 @@ +from imp import find_module + + class RequireModule(object): """Decorator that raises import error if specified module isn't found.""" @@ -6,7 +9,7 @@ def __init__(self, module_name): def _require_module(self): try: - __import__(self.module_name) + find_module(self.module_name) except ImportError: raise ImportError( '%s must be installed to use this helper.' % self.module_name) diff --git a/testtube/helpers.py b/testtube/helpers.py index e226833..ba8b304 100644 --- a/testtube/helpers.py +++ b/testtube/helpers.py @@ -1,13 +1,13 @@ import subprocess -from testtube import conf +from testtube.conf import Settings from testtube.decorators import RequireModule @RequireModule('pep8') def pep8(changed, **kwargs): """Runs the pep8 checker against the changed file.""" - print('Checking PEP 8 compliance of %s...\n' % conf.short_path(changed)) + print 'Checking PEP 8 compliance of %s...\n' % Settings.short_path(changed) subprocess.call(['pep8', changed]) print('\nDone.\n') @@ -15,45 +15,45 @@ def pep8(changed, **kwargs): @RequireModule('pep8') def pep8_all(changed, **kwargs): """Runs the pep8 checker against the entire project.""" - print('Checking PEP 8 compliance of source directory...\n') - subprocess.call(['pep8', conf.SRC_DIR]) + print 'Checking PEP 8 compliance of source directory...\n' + subprocess.call(['pep8', Settings.SRC_DIR]) print('\nDone.\n') @RequireModule('pyflakes') def pyflakes(changed, **kwargs): """Runs pyflakes against the changed file""" - print('Inspecting %s with pyflakes...\n' % conf.short_path(changed)) + print 'Inspecting %s with pyflakes...\n' % Settings.short_path(changed) subprocess.call(['pyflakes', changed]) - print('\nDone.\n') + print '\nDone.\n' @RequireModule('pyflakes') def pyflakes_all(changed, **kwargs): """Runs pyflakes against the entire project""" - print('Inspecting source directory with pyflakes...\n') - subprocess.call(['pyflakes', conf.SRC_DIR]) - print('\nDone.\n') + print 'Inspecting source directory with pyflakes...\n' + subprocess.call(['pyflakes', Settings.SRC_DIR]) + print '\nDone.\n' @RequireModule('frosted') def frosted(changed, **kwargs): """Runs frosted against the changed file""" - print('Inspecting %s with frosted...\n' % conf.short_path(changed)) + print 'Inspecting %s with frosted...\n' % Settings.short_path(changed) subprocess.call(['frosted', changed]) - print('\nDone.\n') + print '\nDone.\n' @RequireModule('frosted') def frosted_all(changed, **kwargs): """Runs frosted against the entire project""" - print('Inspecting source directory with frosted...\n') - subprocess.call(['frosted', '-r', conf.SRC_DIR]) - print('\nDone.\n') + print 'Inspecting source directory with frosted...\n' + subprocess.call(['frosted', '-r', Settings.SRC_DIR]) + print '\nDone.\n' @RequireModule('nose') def nosetests_all(changed, **kwargs): """Run nosetests against the entire project if any file changes.""" - print('Running nosetests...') + print 'Running nosetests...' subprocess.call(['nosetests']) diff --git a/testtube/runner.py b/testtube/runner.py index a6d5378..c6c267d 100644 --- a/testtube/runner.py +++ b/testtube/runner.py @@ -1,6 +1,6 @@ import re -from testtube import conf +from testtube.conf import Settings def _inspect_path(path, pattern): @@ -23,8 +23,8 @@ def _test_path(path, tests, kwargs): def run_tests(path): - """Runs the corresponding tests if path matches against conf.PATTERNS""" - for pattern, tests in conf.PATTERNS: + """Runs the corresponding tests if path matches in Settings.PATTERNS""" + for pattern, tests in Settings.PATTERNS: run_tests, kwargs = _inspect_path(path, pattern) if run_tests: diff --git a/testtube/tests/test_conf.py b/testtube/tests/test_conf.py index 60a1d5d..ae0de20 100644 --- a/testtube/tests/test_conf.py +++ b/testtube/tests/test_conf.py @@ -1,34 +1,30 @@ import os -import sys -from testtube import conf +from testtube.conf import Settings from . import test_settings, unittest class ConfTestCase(unittest.TestCase): def setUp(self): - # Add the tests dir to sys.path so test_settings is importable by - # testtube's conf module - sys.path.append(os.path.dirname(__file__)) - self.settings = test_settings - self.conf = conf - self.conf.configure('foo/', 'test_settings') + self.settings = Settings + self.settings.configure('foo/', 'testtube/tests/test_settings.py') -class ConfModuleConfigureMethod(ConfTestCase): +class SettingsModuleConfigureMethod(ConfTestCase): def test_should_set_the_SRC_DIR(self): """should set the SRC_DIR""" - self.assertEqual(self.conf.SRC_DIR, os.path.join(os.getcwd(), 'foo')) + self.assertEqual( + self.settings.SRC_DIR, os.path.join(os.getcwd(), 'foo')) def test_should_set_PATTERNS_to_setting_modules_PATTERNS_property(self): - self.assertEqual(self.conf.PATTERNS, self.settings.PATTERNS) + self.assertEqual(self.settings.PATTERNS, self.settings.PATTERNS) class Shortpath(ConfTestCase): - """conf.short_path()""" + """Settings' short_path() method""" def test_removes_SRC_DIR_from_the_passed_path(self): - """removes SRC_DIR from the passed path""" + """removes Settings.SRC_DIR from the passed path""" sample_file = os.path.join(os.getcwd(), 'foo/sample.py') - self.assertEqual(conf.short_path(sample_file), 'sample.py') + self.assertEqual(self.settings.short_path(sample_file), 'sample.py') diff --git a/testtube/tests/test_helpers.py b/testtube/tests/test_helpers.py index 0999e99..c39b3a5 100644 --- a/testtube/tests/test_helpers.py +++ b/testtube/tests/test_helpers.py @@ -1,6 +1,7 @@ from . import patch, unittest -from testtube import conf, helpers +from testtube.conf import Settings +from testtube import helpers class SubprocessUsingHelperTest(unittest.TestCase): @@ -18,7 +19,7 @@ def test_should_call_pep8_and_pass_it_the_specified_file(self): class Pep8_allHelperTest(SubprocessUsingHelperTest): def test_should_call_pep8_against_the_entire_project(self): - conf.SRC_DIR = 'yay/' + Settings.SRC_DIR = 'yay/' helpers.pep8_all('a.py') self.subprocess_patcher.assert_called_once_with(['pep8', 'yay/']) @@ -31,7 +32,7 @@ def test_should_call_pyflakes_and_pass_it_the_specified_file(self): class Pyflakes_allHelperTest(SubprocessUsingHelperTest): def test_should_call_pyflakes_and_pass_it_the_project_dir(self): - conf.SRC_DIR = 'yay/' + Settings.SRC_DIR = 'yay/' helpers.pyflakes_all('') self.subprocess_patcher.assert_called_once_with(['pyflakes', 'yay/']) @@ -44,7 +45,7 @@ def test_should_call_frosted_and_pass_it_the_specified_file(self): class Frosted_allHelperTest(SubprocessUsingHelperTest): def test_should_call_frosted_and_pass_it_the_project_dir(self): - conf.SRC_DIR = 'yay/' + Settings.SRC_DIR = 'yay/' helpers.frosted_all('') self.subprocess_patcher.assert_called_once_with( ['frosted', '-r', 'yay/']) diff --git a/testtube/tests/test_runner.py b/testtube/tests/test_runner.py index 9a35d13..e8b9beb 100644 --- a/testtube/tests/test_runner.py +++ b/testtube/tests/test_runner.py @@ -1,6 +1,7 @@ from . import Mock, unittest -from testtube import runner, conf +from testtube.conf import Settings +from testtube import runner class Inspect_pathTest(unittest.TestCase): @@ -35,7 +36,7 @@ class Run_testsTest(unittest.TestCase): """testtube.runner.run_tests()""" def setUp(self): self.mock_test = Mock() - conf.PATTERNS = ((r'.*', [self.mock_test]),) + Settings.PATTERNS = ((r'.*', [self.mock_test]),) def test_should_call_tests_specified_in_conf_on_pattern_match(self): runner.run_tests('yay/') From d46283e83132418b184ba6f0759600aee265c37b Mon Sep 17 00:00:00 2001 From: Thomas W Date: Thu, 10 Apr 2014 15:42:09 -0700 Subject: [PATCH 02/37] Readme updates --- README.md | 69 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 36 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 21ece03..0bd6ff5 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,16 @@ a given path for file changes. It could fairly be described as a simpler (read: easier to use) implementation of watchdog's included "watchmedo" utility. - ## Installation - Install testtube like you'd install any other python package: - pip install testtube - +``` +pip install testtube +``` ## Usage - ### Configure testtube The simplest way to configure testtube is to drop a tube.py file in whatever @@ -34,11 +32,13 @@ expression and a list of tests to run. Here's an example: - from testtube.helpers import pep8_all, pyflakes_all, nosetests_all +```python +from testtube.helpers import pep8_all, pyflakes_all, nosetests_all - PATTERNS = ( - (r'.*\.py', [pep8_all, pyflakes_all, nosetests_all]), - ) +PATTERNS = ( + (r'.*\.py', [pep8_all, pyflakes_all, nosetests_all]), +) +``` Given the configuration above, testtube will match the full path to the changed file against `r'.*\.py'`. If it matches, it will then run the @@ -50,7 +50,6 @@ They are designed to save you from writing your own tests as much as possible. If they don't meet your needs, see the "Writing your own tests" section below. - ### Stir it > stir @@ -62,44 +61,48 @@ file into your project root, then you shouldn't need to specify any parameters assuming you execute stir from that directory. If you've customized things a bit, `stir -h` will light the way: - usage: stir [-h] [--src_dir SRC_DIR] [--settings SETTINGS] +``` +$ stir -h +usage: stir [-h] [--src_dir SRC_DIR] [--settings SETTINGS] - Watch a directory and run a custom set of tests whenever a file changes. - - optional arguments: - -h, --help show this help message and exit - --src_dir SRC_DIR The directory to watch for changes. (Defaults to - CWD) - --settings SETTINGS The testtube settings module that defines which - tests to run. (Defaults to "tube" - the settings - module must be importable from your current working - directory) +Watch a directory and run a custom set of tests whenever a file changes. +optional arguments: + -h, --help show this help message and exit + --src_dir SRC_DIR The directory to watch for changes. (Defaults to CWD) + --settings SETTINGS Path to a testtube settings file that defines which + tests to run (Defaults to "tube.py" - your settings file + must be importable and the path must be relative to + your CWD) +``` ### Writing your own tests + If the included helpers don't do what you need, you can write your own tests right in your settings module. Simply define a callable that accepts at least one argument and add it to your patterns list: - def mytest(changed_file): - print "Oh snap, %s just changed" % changed_file +```python +def mytest(changed_file): + print "Oh snap, %s just changed" % changed_file - PATTERNS = ( - (r'.*', [mytest]), - ) +PATTERNS = ( + (r'.*', [mytest]), +) +``` Fortunately, tests can be a bit more clever than that. If you define it like the following, testtube will pass it all of the named sub patterns in your regular expression: - def mysmartertest(changed_file, **kwargs): - print "%s in %s/ changed." % (changed_file, kwargs['dir']) - - PATTERNS = ( - (r'.*/(?P[^/]*)/.*\.py', [mysmartertest]), - ) - +```python +def mysmartertest(changed_file, **kwargs): + print "%s in %s/ changed." % (changed_file, kwargs['dir']) +PATTERNS = ( + (r'.*/(?P[^/]*)/.*\.py$', [mysmartertest]), +) +``` ## Everything else From a1dde52c6a3a0d2e38cc997a1af742f5a2a585a1 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Thu, 10 Apr 2014 16:19:03 -0700 Subject: [PATCH 03/37] Make runner methods public --- testtube/runner.py | 13 ++++++------- testtube/tests/test_runner.py | 12 ++++++------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/testtube/runner.py b/testtube/runner.py index c6c267d..57e1d87 100644 --- a/testtube/runner.py +++ b/testtube/runner.py @@ -3,10 +3,9 @@ from testtube.conf import Settings -def _inspect_path(path, pattern): - """Return True if pattern matches path as well as the set of named - subpattern matches. - +def inspect_path(path, pattern): + """ + Return True and set of named subpattern matches if pattern matches path. """ match = re.match(pattern, path) @@ -16,7 +15,7 @@ def _inspect_path(path, pattern): return True, match.groupdict() -def _test_path(path, tests, kwargs): +def test_path(path, tests, kwargs): """Runs a set of tests against a specified path passing kwargs to each.""" for test in tests: test(path, **kwargs) @@ -25,8 +24,8 @@ def _test_path(path, tests, kwargs): def run_tests(path): """Runs the corresponding tests if path matches in Settings.PATTERNS""" for pattern, tests in Settings.PATTERNS: - run_tests, kwargs = _inspect_path(path, pattern) + run_tests, kwargs = inspect_path(path, pattern) if run_tests: - _test_path(path, tests, kwargs) + test_path(path, tests, kwargs) print('=' * 58) diff --git a/testtube/tests/test_runner.py b/testtube/tests/test_runner.py index e8b9beb..b45d003 100644 --- a/testtube/tests/test_runner.py +++ b/testtube/tests/test_runner.py @@ -5,28 +5,28 @@ class Inspect_pathTest(unittest.TestCase): - """testtube.runner._inspect_path()""" + """testtube.runner.inspect_path()""" def test_should_return_false_if_the_path_doesnt_match_the_pattern(self): - match, kwargs = runner._inspect_path('no/matches/path/', r'kittens') + match, kwargs = runner.inspect_path('no/matches/path/', r'kittens') self.assertFalse(match) def test_should_return_true_if_path_matches_the_pattern(self): - match, kwargs = runner._inspect_path('kittens/', r'^kittens',) + match, kwargs = runner.inspect_path('kittens/', r'^kittens',) self.assertTrue(match) def test_should_return_named_subpatterns_if_any(self): - match, kwargs = runner._inspect_path( + match, kwargs = runner.inspect_path( 'kittens/yay.py', r'(?P.*/).*.py') self.assertEqual(kwargs, {'dir': 'kittens/'}) class test_pathTest(unittest.TestCase): - """testtube.runner._test_path()""" + """testtube.runner.test_path()""" def setUp(self): self.callables = [Mock(), Mock(), Mock()] def test_should_pass_path_and_kwargs_to_a_set_of_callables(self): - runner._test_path('yay/', self.callables, {'foo': 'bar'}) + runner.test_path('yay/', self.callables, {'foo': 'bar'}) for callable_mock in self.callables: callable_mock.asser_called_once_with(path='yay/', foo='bar') From a7482ed245fbda3aa1504d901d7dd470a65319a8 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Thu, 17 Apr 2014 17:25:13 -0700 Subject: [PATCH 04/37] WIP --- testtube/decorators.py | 22 ------ testtube/helpers.py | 129 ++++++++++++++++++++++----------- testtube/runner.py | 9 ++- testtube/tests/test_helpers.py | 1 + 4 files changed, 96 insertions(+), 65 deletions(-) delete mode 100644 testtube/decorators.py diff --git a/testtube/decorators.py b/testtube/decorators.py deleted file mode 100644 index 020ee75..0000000 --- a/testtube/decorators.py +++ /dev/null @@ -1,22 +0,0 @@ -from imp import find_module - - -class RequireModule(object): - """Decorator that raises import error if specified module isn't found.""" - - def __init__(self, module_name): - self.module_name = module_name - - def _require_module(self): - try: - find_module(self.module_name) - except ImportError: - raise ImportError( - '%s must be installed to use this helper.' % self.module_name) - - def __call__(self, func, *args, **kwargs): - def wrapper(*args, **kwargs): - self._require_module() - return func(*args, **kwargs) - - return wrapper diff --git a/testtube/helpers.py b/testtube/helpers.py index ba8b304..2572c1e 100644 --- a/testtube/helpers.py +++ b/testtube/helpers.py @@ -1,59 +1,104 @@ import subprocess +import sys from testtube.conf import Settings -from testtube.decorators import RequireModule -@RequireModule('pep8') -def pep8(changed, **kwargs): - """Runs the pep8 checker against the changed file.""" - print 'Checking PEP 8 compliance of %s...\n' % Settings.short_path(changed) - subprocess.call(['pep8', changed]) - print('\nDone.\n') +class HardTestFailure(Exception): + pass -@RequireModule('pep8') -def pep8_all(changed, **kwargs): - """Runs the pep8 checker against the entire project.""" - print 'Checking PEP 8 compliance of source directory...\n' - subprocess.call(['pep8', Settings.SRC_DIR]) - print('\nDone.\n') +class Helper(object): + command = '' + all_files = False + def setup(self, changed, *args, **kwargs): + test_name = self.__class__.__name__ -@RequireModule('pyflakes') -def pyflakes(changed, **kwargs): - """Runs pyflakes against the changed file""" - print 'Inspecting %s with pyflakes...\n' % Settings.short_path(changed) - subprocess.call(['pyflakes', changed]) - print '\nDone.\n' + if self.all_files: + print "Executing %s against source directory.\n" % test_name + else: + print 'Executing %s against %s...\n' % (test_name, changed) + def test(self, changed, *args, **kwargs): + return self.execute_system_command(changed, *args, **kwargs) -@RequireModule('pyflakes') -def pyflakes_all(changed, **kwargs): - """Runs pyflakes against the entire project""" - print 'Inspecting source directory with pyflakes...\n' - subprocess.call(['pyflakes', Settings.SRC_DIR]) - print '\nDone.\n' + def tear_down(self, changed, result, *args, **kwargs): + print 'Done.\n' + def success(self, changed, result, *args, **kwargs): + pass -@RequireModule('frosted') -def frosted(changed, **kwargs): - """Runs frosted against the changed file""" - print 'Inspecting %s with frosted...\n' % Settings.short_path(changed) - subprocess.call(['frosted', changed]) - print '\nDone.\n' + def failure(self, changed, result, *args, **kwargs): + sys.stdout.write('\a' * 3) + raise HardTestFailure("Fail fast is enabled, aborting test run.") -@RequireModule('frosted') -def frosted_all(changed, **kwargs): - """Runs frosted against the entire project""" - print 'Inspecting source directory with frosted...\n' - subprocess.call(['frosted', '-r', Settings.SRC_DIR]) - print '\nDone.\n' + def execute_system_command(self, changed, *args, **kwargs): + if not self.command: + return True + return subprocess.call([self.command] + self.get_args(changed)) == 0 -@RequireModule('nose') -def nosetests_all(changed, **kwargs): - """Run nosetests against the entire project if any file changes.""" - print 'Running nosetests...' - subprocess.call(['nosetests']) + def get_args(self, changed, *args, **kwargs): + if self.all_files: + return [Settings.SRC_DIR] + + return [changed] + + def __call__(self, changed, *args, **kwargs): + self.setup(changed, *args, **kwargs) + + result = self.test(changed, *args, **kwargs) + + if result: + self.success(changed, result, *args, **kwargs) + + if not result: + self.failure(changed, result, *args, **kwargs) + + self.tear_down(changed, result, *args, **kwargs) + + +class Pep8(Helper): + command = 'pep8' + + +class Pep8All(Pep8): + all_files = True + + +class Pyflakes(Helper): + command = 'pyflakes' + + +class PyflakesAll(Pyflakes): + all_files = True + + +class Frosted(Helper): + command = 'frosted' + + +class FrostedAll(Frosted): + all_files = True + + def get_args(self, *args, **kwargs): + return ['-r', Settings.SRC_DIR] + + +class NoseTestsAll(Helper): + command = 'nosetests' + all_files = True + + def get_args(self, *args, **kwargs): + return [] + + +pep8 = Pep8() +pep8_all = Pep8All() +pyflakes = Pyflakes() +pyflakes_all = PyflakesAll() +frosted = Frosted() +frosted_all = FrostedAll() +nosetests_all = NoseTestsAll() diff --git a/testtube/runner.py b/testtube/runner.py index 57e1d87..5943b7c 100644 --- a/testtube/runner.py +++ b/testtube/runner.py @@ -1,6 +1,7 @@ import re from testtube.conf import Settings +from testtube.helpers import HardTestFailure def inspect_path(path, pattern): @@ -18,7 +19,13 @@ def inspect_path(path, pattern): def test_path(path, tests, kwargs): """Runs a set of tests against a specified path passing kwargs to each.""" for test in tests: - test(path, **kwargs) + try: + test(path, **kwargs) + except HardTestFailure: + print + print "Test failed and fail fast is enabled. Aborting test run." + print + break def run_tests(path): diff --git a/testtube/tests/test_helpers.py b/testtube/tests/test_helpers.py index c39b3a5..a0366ed 100644 --- a/testtube/tests/test_helpers.py +++ b/testtube/tests/test_helpers.py @@ -9,6 +9,7 @@ class SubprocessUsingHelperTest(unittest.TestCase): def setUp(self): self.subprocess_patcher = patch("testtube.helpers.subprocess.call") self.subprocess_patcher = self.subprocess_patcher.start() + self.subprocess_patcher.return_value = 0 class Pep8HelperTest(SubprocessUsingHelperTest): From f1c44a3f2f07e3917a3777c9f648b20864d5f803 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 15 Jun 2014 23:50:55 -0700 Subject: [PATCH 05/37] Get rid of the TODO file It is easier for me to manage these things as github issues --- TODO.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 1c86074..0000000 --- a/TODO.md +++ /dev/null @@ -1,13 +0,0 @@ -# Fail Fast Feature -Users should be able to configure a test tuple so that it "fails fast" (as -soon as one of the tests in the tuple fails, the entire set stops running for -that iteration). - -* `PATTERNS` should allow a third tuple value (a dictionary) to be used for -configuring that set of tests. -* Helpers should be modified to return a boolean indicating success or failure -* runner.py should be modified to respect the `fail_fast` configuration option -* Helpers should be modified so that they always expect to be passed that -optional configuration dictionary? This is unnecessary to achieve this feature -but might be something that will be needed later? (If this is done, be sure to -update the docs) \ No newline at end of file From 6d4dab14816db93c1bfbc1fea8f44912225b7937 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 15 Jun 2014 23:51:46 -0700 Subject: [PATCH 06/37] Upgrade linting requirements to newer versions --- requirements.txt | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/requirements.txt b/requirements.txt index fe3a0ef..f0e5095 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,12 +2,10 @@ watchdog==0.7.1 # Testing requirements coveralls==0.4.1 -pep8==1.3.3 pinocchio==0.4.1 -frosted==1.4.0 +flake8==2.1.0 +frosted==1.4.1 mock==1.0.1 +pep257==0.3.2 nose==1.3.1 unittest2==0.5.1 - -# Helper requirements -pyflakes==0.5.0 From d62ff64c12c2c3ebe5d046e9d71a831d26ba890b Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 15 Jun 2014 23:52:01 -0700 Subject: [PATCH 07/37] Install termcolor so we can colorize output --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index f0e5095..423ba2c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ watchdog==0.7.1 +termcolor==1.1.0 # Testing requirements coveralls==0.4.1 From 164428a60f36b03fb8cc6b6e70f5443e5453a695 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 15 Jun 2014 23:52:30 -0700 Subject: [PATCH 08/37] Make the settings module a normal class I'm not sure why I made it a module in the first place? --- testtube/conf.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/testtube/conf.py b/testtube/conf.py index ad908c6..9c667c2 100644 --- a/testtube/conf.py +++ b/testtube/conf.py @@ -2,10 +2,9 @@ import argparse import imp import os -import types -class Settings(types.ModuleType): +class Settings(object): # testube default settings CWD_SRC_DIR = '' SRC_DIR = os.getcwd() From 140882559ae56b3fc9c0431676bcd76afc38711c Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 15 Jun 2014 23:52:45 -0700 Subject: [PATCH 09/37] pep257 fixes --- testtube/handlers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/testtube/handlers.py b/testtube/handlers.py index 95daf17..e34a082 100644 --- a/testtube/handlers.py +++ b/testtube/handlers.py @@ -4,6 +4,9 @@ class PyChangeHandler(FileSystemEventHandler): + """Watchdog handler that executes the test runner on file changes.""" + def on_any_event(self, event): + """Execute the test suite whenever files change.""" run_tests(event.src_path) From dc98ced8355487bd8cf1639fcbf184ef5e12c72c Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 15 Jun 2014 23:53:55 -0700 Subject: [PATCH 10/37] Import ANY in __init__ One of the tests will need it. This also removes the frosted-doesn't-have-noq-support workaround because now it does. --- testtube/tests/__init__.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/testtube/tests/__init__.py b/testtube/tests/__init__.py index a398209..92d6bcd 100644 --- a/testtube/tests/__init__.py +++ b/testtube/tests/__init__.py @@ -6,11 +6,6 @@ import unittest # NOQA if sys.version_info < (3,): - from mock import Mock, patch # NOQA + from mock import Mock, patch, ANY # NOQA else: - from unittest.mock import Mock, patch # NOQA - - -# Frosted doesn't yet support noqa flags, so this hides the imported/unused -# complaints -Mock, patch, unittest + from unittest.mock import Mock, patch, ANY # NOQA From dcc891ab506a07473033f9ac512fc0b324533d6d Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 15 Jun 2014 23:54:24 -0700 Subject: [PATCH 11/37] Remove helper tests This suite needs to be entirely rewritten given the new Helper base class --- testtube/tests/test_helpers.py | 58 ---------------------------------- 1 file changed, 58 deletions(-) diff --git a/testtube/tests/test_helpers.py b/testtube/tests/test_helpers.py index a0366ed..e69de29 100644 --- a/testtube/tests/test_helpers.py +++ b/testtube/tests/test_helpers.py @@ -1,58 +0,0 @@ -from . import patch, unittest - -from testtube.conf import Settings -from testtube import helpers - - -class SubprocessUsingHelperTest(unittest.TestCase): - """TestCase with testtube.helpers.subprocess.call pre-patched.""" - def setUp(self): - self.subprocess_patcher = patch("testtube.helpers.subprocess.call") - self.subprocess_patcher = self.subprocess_patcher.start() - self.subprocess_patcher.return_value = 0 - - -class Pep8HelperTest(SubprocessUsingHelperTest): - def test_should_call_pep8_and_pass_it_the_specified_file(self): - helpers.pep8('a.py') - self.subprocess_patcher.assert_called_once_with(['pep8', 'a.py']) - - -class Pep8_allHelperTest(SubprocessUsingHelperTest): - def test_should_call_pep8_against_the_entire_project(self): - Settings.SRC_DIR = 'yay/' - helpers.pep8_all('a.py') - self.subprocess_patcher.assert_called_once_with(['pep8', 'yay/']) - - -class PyflakesHelperTest(SubprocessUsingHelperTest): - def test_should_call_pyflakes_and_pass_it_the_specified_file(self): - helpers.pyflakes('a.py') - self.subprocess_patcher.assert_called_once_with(['pyflakes', 'a.py']) - - -class Pyflakes_allHelperTest(SubprocessUsingHelperTest): - def test_should_call_pyflakes_and_pass_it_the_project_dir(self): - Settings.SRC_DIR = 'yay/' - helpers.pyflakes_all('') - self.subprocess_patcher.assert_called_once_with(['pyflakes', 'yay/']) - - -class FrostedHelperTest(SubprocessUsingHelperTest): - def test_should_call_frosted_and_pass_it_the_specified_file(self): - helpers.frosted('a.py') - self.subprocess_patcher.assert_called_once_with(['frosted', 'a.py']) - - -class Frosted_allHelperTest(SubprocessUsingHelperTest): - def test_should_call_frosted_and_pass_it_the_project_dir(self): - Settings.SRC_DIR = 'yay/' - helpers.frosted_all('') - self.subprocess_patcher.assert_called_once_with( - ['frosted', '-r', 'yay/']) - - -class Nosetests_allHelperTest(SubprocessUsingHelperTest): - def test_should_call_nosetests(self): - helpers.nosetests_all('') - self.subprocess_patcher.assert_called_once_with(['nosetests']) From b30e0c35810111e82534f85460c02d2b1800de4c Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 15 Jun 2014 23:54:54 -0700 Subject: [PATCH 12/37] WIP --- testtube/helpers.py | 108 +++++++++++++++++++--------------- testtube/runner.py | 73 +++++++++++++++++++---- testtube/tests/test_runner.py | 21 ++++--- tube.py | 24 +++++++- 4 files changed, 156 insertions(+), 70 deletions(-) diff --git a/testtube/helpers.py b/testtube/helpers.py index 2572c1e..5bf31ae 100644 --- a/testtube/helpers.py +++ b/testtube/helpers.py @@ -1,6 +1,8 @@ import subprocess import sys +from termcolor import colored + from testtube.conf import Settings @@ -10,95 +12,107 @@ class HardTestFailure(Exception): class Helper(object): command = '' - all_files = False - def setup(self, changed, *args, **kwargs): - test_name = self.__class__.__name__ + def __init__(self, **kwargs): + self.all_files = False + self.fail_fast = False + self.bells = 3 + self.name = self.__class__.__name__ + + # Override default settings with passed in values. + for setting, value in kwargs.iteritems(): + setattr(self, setting, value) + # These properites are provided by __call__ and are not configurable. + self.changed = '' + self.match = '' + + def setup(self): if self.all_files: - print "Executing %s against source directory.\n" % test_name - else: - print 'Executing %s against %s...\n' % (test_name, changed) + print 'Executing %s against all matching files.\n' % self.name - def test(self, changed, *args, **kwargs): - return self.execute_system_command(changed, *args, **kwargs) + if not self.all_files: + print 'Executing %s against %s...\n' % (self.name, self.changed) - def tear_down(self, changed, result, *args, **kwargs): - print 'Done.\n' + def tear_down(self, result): + print - def success(self, changed, result, *args, **kwargs): - pass + def success(self, result): + print colored('Test passed.', 'green') - def failure(self, changed, result, *args, **kwargs): - sys.stdout.write('\a' * 3) + def failure(self, result): + sys.stdout.write('\a' * self.bells) + print colored('Test failed.', 'red') - raise HardTestFailure("Fail fast is enabled, aborting test run.") + if self.fail_fast: + raise HardTestFailure('Fail fast is enabled, aborting test run.') - def execute_system_command(self, changed, *args, **kwargs): + def test(self): if not self.command: return True - return subprocess.call([self.command] + self.get_args(changed)) == 0 + return subprocess.call([self.command] + self.get_args()) == 0 - def get_args(self, changed, *args, **kwargs): + def get_args(self): if self.all_files: return [Settings.SRC_DIR] - return [changed] + return [self.changed] + + def __call__(self, changed, match): + self.changed = changed + self.match = match - def __call__(self, changed, *args, **kwargs): - self.setup(changed, *args, **kwargs) + self.setup() - result = self.test(changed, *args, **kwargs) + result = self.test() if result: - self.success(changed, result, *args, **kwargs) + self.success(result) if not result: - self.failure(changed, result, *args, **kwargs) + self.failure(result) - self.tear_down(changed, result, *args, **kwargs) + self.tear_down(result) + + return result class Pep8(Helper): command = 'pep8' -class Pep8All(Pep8): - all_files = True - - class Pyflakes(Helper): command = 'pyflakes' -class PyflakesAll(Pyflakes): - all_files = True - - class Frosted(Helper): command = 'frosted' + def get_args(self): + if self.all_files: + return ['-r', Settings.SRC_DIR] -class FrostedAll(Frosted): - all_files = True - - def get_args(self, *args, **kwargs): - return ['-r', Settings.SRC_DIR] + return [self.changed] -class NoseTestsAll(Helper): +class Nosetests(Helper): command = 'nosetests' - all_files = True + + def __init__(self, **kwargs): + super(Nosetests, self).__init__() + + # Nosetests only works on all files, so override any config for this + # value. + self.all_files = True def get_args(self, *args, **kwargs): return [] -pep8 = Pep8() -pep8_all = Pep8All() -pyflakes = Pyflakes() -pyflakes_all = PyflakesAll() -frosted = Frosted() -frosted_all = FrostedAll() -nosetests_all = NoseTestsAll() +class Flake8(Helper): + command = 'flake8' + + +class Pep257(Helper): + command = 'pep257' diff --git a/testtube/runner.py b/testtube/runner.py index 5943b7c..eb125b9 100644 --- a/testtube/runner.py +++ b/testtube/runner.py @@ -1,5 +1,7 @@ import re +from termcolor import colored + from testtube.conf import Settings from testtube.helpers import HardTestFailure @@ -13,26 +15,73 @@ def inspect_path(path, pattern): if not match: return False, {} - return True, match.groupdict() + return True, match -def test_path(path, tests, kwargs): +def test_path(path, test_group, groupdict): """Runs a set of tests against a specified path passing kwargs to each.""" - for test in tests: + test_results = [] + + for test in test_group: try: - test(path, **kwargs) + test_result = test(path, groupdict) except HardTestFailure: - print - print "Test failed and fail fast is enabled. Aborting test run." - print + test_result = False + print colored('\nFail fast is enabled. Aborting tests.\n', 'red') break + finally: + test_results.append((test, test_result)) + + return test_results + + +def print_test_report(results): + if not results: + return + + print "Test Report\n" + + for count, suite_group in enumerate(results, 1): + test_group_results = "Test group %s:\t" % count + + for test, result in suite_group: + color = 'green' if result else 'red' + files = test.changed if not test.all_files else 'all matches' + test_group_results += colored( + '%s (%s)\t' % (test.name, files), color) + + print test_group_results + + print('=' * 71) def run_tests(path): """Runs the corresponding tests if path matches in Settings.PATTERNS""" - for pattern, tests in Settings.PATTERNS: - run_tests, kwargs = inspect_path(path, pattern) + suite_results = [] + + for suite_conf in Settings.PATTERNS: + # Ensure there are three elements in the tuple (the third element + # in suite configs is optional) + conf = suite_conf + ({}, ) if len(suite_conf) < 3 else suite_conf + + pattern, tests, group_conf = conf + group_fail_fast = group_conf.get('fail_fast', False) + + run_tests, regex_match = inspect_path(path, pattern) + + if not run_tests: + continue + + group_results = test_path(path, tests, regex_match) + suite_results.append(group_results) + all_passed = all(result for test, result in group_results) + + if not all_passed and group_fail_fast: + print colored( + 'Aborting subsequent test groups. Fail fast enabled.', 'red') + print('=' * 71) + break + + print('=' * 71) - if run_tests: - test_path(path, tests, kwargs) - print('=' * 58) + print_test_report(suite_results) diff --git a/testtube/tests/test_runner.py b/testtube/tests/test_runner.py index b45d003..4831aa2 100644 --- a/testtube/tests/test_runner.py +++ b/testtube/tests/test_runner.py @@ -1,4 +1,6 @@ -from . import Mock, unittest +import re + +from . import ANY, Mock, unittest from testtube.conf import Settings from testtube import runner @@ -7,17 +9,18 @@ class Inspect_pathTest(unittest.TestCase): """testtube.runner.inspect_path()""" def test_should_return_false_if_the_path_doesnt_match_the_pattern(self): - match, kwargs = runner.inspect_path('no/matches/path/', r'kittens') - self.assertFalse(match) + matches, regex_match = runner.inspect_path( + 'no/matches/path/', r'kittens') + self.assertFalse(matches) def test_should_return_true_if_path_matches_the_pattern(self): - match, kwargs = runner.inspect_path('kittens/', r'^kittens',) - self.assertTrue(match) + matches, regex_match = runner.inspect_path('kittens/', r'^kittens') + self.assertTrue(matches) - def test_should_return_named_subpatterns_if_any(self): - match, kwargs = runner.inspect_path( + def test_should_return_regex_match_object(self): + matches, regex_match = runner.inspect_path( 'kittens/yay.py', r'(?P.*/).*.py') - self.assertEqual(kwargs, {'dir': 'kittens/'}) + self.assertIsInstance(regex_match, type(re.match("", ""))) class test_pathTest(unittest.TestCase): @@ -40,4 +43,4 @@ def setUp(self): def test_should_call_tests_specified_in_conf_on_pattern_match(self): runner.run_tests('yay/') - self.mock_test.assert_called_once_with('yay/') + self.mock_test.assert_called_once_with('yay/', ANY) diff --git a/tube.py b/tube.py index ceed6f1..bf8d656 100644 --- a/tube.py +++ b/tube.py @@ -1,5 +1,25 @@ -from testtube.helpers import pep8_all, frosted_all, nosetests_all +from testtube.helpers import Frosted, Nosetests, Pep257, Flake8 PATTERNS = ( - (r'.*\.py$', [pep8_all, frosted_all, nosetests_all]), + # Run pep257 check against a file if it changes, excluding files that have + # test_ or tube.py in the name. + # If this test fails, don't make any noise (0 bells on failure) + ( + r'((?!test_)(?!tube\.py).)*\.py$', + [Pep257(bells=0)] + ), + # Run flake8 and Frosted on all python files when they change. If these + # checks fail, abort the entire test suite because it might be due to a + # syntax error. There's no point running the subsequent tests if there + # is such an error. + ( + r'.*\.py$', + [Flake8(all_files=True), Frosted(all_files=True)], + {'fail_fast': True} + ), + # Run the test suite whenever python files change + ( + r'.*\.py$', + [Nosetests()] + ) ) From bc486a54a63ef9f638ed87794615b5cb2392b321 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Mon, 16 Jun 2014 17:11:02 -0700 Subject: [PATCH 13/37] wip leftover changes from the weekend --- testtube/handlers.py | 5 +- testtube/runner.py | 169 +++++++++++++++++++++----------- testtube/tests/test_handlers.py | 6 +- testtube/tests/test_runner.py | 46 --------- 4 files changed, 120 insertions(+), 106 deletions(-) delete mode 100644 testtube/tests/test_runner.py diff --git a/testtube/handlers.py b/testtube/handlers.py index e34a082..95c171a 100644 --- a/testtube/handlers.py +++ b/testtube/handlers.py @@ -1,6 +1,6 @@ from watchdog.events import FileSystemEventHandler -from testtube.runner import run_tests +from testtube.runner import SuiteRunner class PyChangeHandler(FileSystemEventHandler): @@ -9,4 +9,5 @@ class PyChangeHandler(FileSystemEventHandler): def on_any_event(self, event): """Execute the test suite whenever files change.""" - run_tests(event.src_path) + test_runner = SuiteRunner() + test_runner.run(event.src_path) diff --git a/testtube/runner.py b/testtube/runner.py index eb125b9..89d1b94 100644 --- a/testtube/runner.py +++ b/testtube/runner.py @@ -1,3 +1,4 @@ +"""Utilities for executing testtube suites against changed files.""" import re from termcolor import colored @@ -6,82 +7,140 @@ from testtube.helpers import HardTestFailure -def inspect_path(path, pattern): - """ - Return True and set of named subpattern matches if pattern matches path. +class ResultCollection(list): + + """List representing results of testtube test group runs. + + Each entry in the list should be tuple containing a test (Helper subclass + or other callable with a similar interface) and a result (bool). + """ - match = re.match(pattern, path) - if not match: - return False, {} + @property + def passed(self): + """Return True if all the tests in the result collection passed.""" + return all(result for test, result in self) + + +class TestCollection(object): + + """Pattern, test list, and config grouping.""" + + fail_fast_msg = colored('Fail fast is enabled. Aborting tests.', 'red') + + def __init__(self, pattern, tests, conf=None): + """Build a test collection given a regex, a test list and configuration. + + Kwargs: + pattern - a regular expression to match against paths of changed files + tests - a list of callables to execute against a changed path + conf - an optional configuration dict + + Valid conf keys: + fail_fast - Causes the test run to abort if any tests in the group fail + + All other values in the conf dict are ignored by default. + + """ + self.pattern = pattern + self.tests = tests + self.conf = conf or {} + + @property + def fail_fast(self): + """Return True if the TestCollection is configured to fail fast. + + Fail fast collections abort subsequent test group processing if any + tests in their group fail. + + """ + self.conf.get('fail_fast', False) + + def apply(self, path): + """Run tests against a path if it matches the configured pattern. + + Returns a ResultCollection containg the success/fail status of the + tests in the collection. + + """ + applicable, regex_match = self._check_path(path) + results = ResultCollection() + + if not applicable: + return results + + for test in self.tests: + result = False - return True, match + try: + result = test(path, regex_match) + except HardTestFailure: + result = False + break + finally: + results.append((test, result)) + return results -def test_path(path, test_group, groupdict): - """Runs a set of tests against a specified path passing kwargs to each.""" - test_results = [] + def _check_path(self, path): + match = re.match(self.pattern, path) - for test in test_group: - try: - test_result = test(path, groupdict) - except HardTestFailure: - test_result = False - print colored('\nFail fast is enabled. Aborting tests.\n', 'red') - break - finally: - test_results.append((test, test_result)) + if not match: + return False, {} - return test_results + return True, match -def print_test_report(results): - if not results: - return +class SuiteRunner(object): + fail_fast_msg = colored( + 'Aborting subsequent test groups. Fail fast enabled.', 'red') + test_divider = '=' * 71 - print "Test Report\n" + def run(self, path): + results = [] - for count, suite_group in enumerate(results, 1): - test_group_results = "Test group %s:\t" % count + for test_group in Settings.PATTERNS: + tests = TestCollection(*test_group) + result = tests.apply(path) - for test, result in suite_group: - color = 'green' if result else 'red' - files = test.changed if not test.all_files else 'all matches' - test_group_results += colored( - '%s (%s)\t' % (test.name, files), color) + if result: + results.append(result) - print test_group_results + if result and not result.passed and tests.fail_fast: + self._render_fail_fast_error() + self._render_divider() + break - print('=' * 71) + if result: + self._render_divider() + if results: + self._render_test_report(results) -def run_tests(path): - """Runs the corresponding tests if path matches in Settings.PATTERNS""" - suite_results = [] + def _render_divider(self): + print self.test_divider - for suite_conf in Settings.PATTERNS: - # Ensure there are three elements in the tuple (the third element - # in suite configs is optional) - conf = suite_conf + ({}, ) if len(suite_conf) < 3 else suite_conf + def _render_fail_fast_error(self): + print self.fail_fast_msg - pattern, tests, group_conf = conf - group_fail_fast = group_conf.get('fail_fast', False) + def _render_test_report(self, results): + if not results: + return - run_tests, regex_match = inspect_path(path, pattern) + print "Test Report\n" - if not run_tests: - continue + for count, suite_group in enumerate(results, 1): + if not suite_group: + continue - group_results = test_path(path, tests, regex_match) - suite_results.append(group_results) - all_passed = all(result for test, result in group_results) + test_group_results = "Test group %s:\t" % count - if not all_passed and group_fail_fast: - print colored( - 'Aborting subsequent test groups. Fail fast enabled.', 'red') - print('=' * 71) - break + for test, result in suite_group: + color = 'green' if result else 'red' + files = test.changed if not test.all_files else 'all matches' + test_group_results += colored( + '%s (%s)\t' % (test.name, files), color) - print('=' * 71) + print test_group_results - print_test_report(suite_results) + self._render_divider() diff --git a/testtube/tests/test_handlers.py b/testtube/tests/test_handlers.py index f09eeb2..b7412d4 100644 --- a/testtube/tests/test_handlers.py +++ b/testtube/tests/test_handlers.py @@ -8,7 +8,7 @@ class PyChangeHandlerTests(unittest.TestCase): def setUp(self): self.handler = PyChangeHandler() - @patch("testtube.handlers.run_tests") - def test_should_execute_test_runner_with_changed_files(self, run_tests): + @patch("testtube.handlers.SuiteRunner") + def test_should_execute_test_runner_with_changed_files(self, test_runner): self.handler.on_any_event(Mock(src_path='foo.py')) - run_tests.assert_called_once_with('foo.py') + test_runner.return_value.run.assert_called_once_with('foo.py') diff --git a/testtube/tests/test_runner.py b/testtube/tests/test_runner.py deleted file mode 100644 index 4831aa2..0000000 --- a/testtube/tests/test_runner.py +++ /dev/null @@ -1,46 +0,0 @@ -import re - -from . import ANY, Mock, unittest - -from testtube.conf import Settings -from testtube import runner - - -class Inspect_pathTest(unittest.TestCase): - """testtube.runner.inspect_path()""" - def test_should_return_false_if_the_path_doesnt_match_the_pattern(self): - matches, regex_match = runner.inspect_path( - 'no/matches/path/', r'kittens') - self.assertFalse(matches) - - def test_should_return_true_if_path_matches_the_pattern(self): - matches, regex_match = runner.inspect_path('kittens/', r'^kittens') - self.assertTrue(matches) - - def test_should_return_regex_match_object(self): - matches, regex_match = runner.inspect_path( - 'kittens/yay.py', r'(?P.*/).*.py') - self.assertIsInstance(regex_match, type(re.match("", ""))) - - -class test_pathTest(unittest.TestCase): - """testtube.runner.test_path()""" - def setUp(self): - self.callables = [Mock(), Mock(), Mock()] - - def test_should_pass_path_and_kwargs_to_a_set_of_callables(self): - runner.test_path('yay/', self.callables, {'foo': 'bar'}) - - for callable_mock in self.callables: - callable_mock.asser_called_once_with(path='yay/', foo='bar') - - -class Run_testsTest(unittest.TestCase): - """testtube.runner.run_tests()""" - def setUp(self): - self.mock_test = Mock() - Settings.PATTERNS = ((r'.*', [self.mock_test]),) - - def test_should_call_tests_specified_in_conf_on_pattern_match(self): - runner.run_tests('yay/') - self.mock_test.assert_called_once_with('yay/', ANY) From 17141cf0f844556c7e5a50318b92cea9e343e169 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Thu, 19 Jun 2014 16:28:23 -0700 Subject: [PATCH 14/37] Move message printing into its own class --- testtube/core.py | 6 ++++- testtube/helpers.py | 28 ++++++++++++--------- testtube/renderer.py | 51 ++++++++++++++++++++++++++++++++++++++ testtube/runner.py | 59 ++++++++++++-------------------------------- 4 files changed, 88 insertions(+), 56 deletions(-) create mode 100644 testtube/renderer.py diff --git a/testtube/core.py b/testtube/core.py index f74c0ac..a114ff6 100644 --- a/testtube/core.py +++ b/testtube/core.py @@ -5,17 +5,21 @@ from testtube.conf import get_arguments, Settings from testtube.handlers import PyChangeHandler +from testtube.renderer import Renderer def main(): + """Configure testtube and begins watching for file changes.""" # Configure the app based on passed arguments Settings.configure(*get_arguments()) + renderer = Renderer() observer = Observer() observer.schedule(PyChangeHandler(), Settings.SRC_DIR, recursive=True) observer.start() - print('testtube is now watching %s for changes...\n' % Settings.SRC_DIR) + renderer.notice( + 'testtube is now watching %s for changes...\n' % Settings.SRC_DIR) try: while True: diff --git a/testtube/helpers.py b/testtube/helpers.py index 5bf31ae..840e4d9 100644 --- a/testtube/helpers.py +++ b/testtube/helpers.py @@ -1,9 +1,7 @@ import subprocess -import sys - -from termcolor import colored from testtube.conf import Settings +from testtube.renderer import Renderer class HardTestFailure(Exception): @@ -12,6 +10,7 @@ class HardTestFailure(Exception): class Helper(object): command = '' + renderer = Renderer() def __init__(self, **kwargs): self.all_files = False @@ -28,21 +27,19 @@ def __init__(self, **kwargs): self.match = '' def setup(self): - if self.all_files: - print 'Executing %s against all matching files.\n' % self.name - - if not self.all_files: - print 'Executing %s against %s...\n' % (self.name, self.changed) + changed = "all matching files" if not self.all_files else self.changed + self.renderer.notice( + 'Executing %s against %s.\n' % (self.name, changed)) def tear_down(self, result): - print + self.renderer.notice() def success(self, result): - print colored('Test passed.', 'green') + self.renderer.success('Test passed.') def failure(self, result): - sys.stdout.write('\a' * self.bells) - print colored('Test failed.', 'red') + self.renderer.audible_alert(self.bells) + self.renderer.failure('Test failed.') if self.fail_fast: raise HardTestFailure('Fail fast is enabled, aborting test run.') @@ -116,3 +113,10 @@ class Flake8(Helper): class Pep257(Helper): command = 'pep257' + + +class PythonSetupPyTest(Helper): + command = 'python' + + def get_args(self): + return ['setup.py', 'test'] diff --git a/testtube/renderer.py b/testtube/renderer.py new file mode 100644 index 0000000..95d7cdf --- /dev/null +++ b/testtube/renderer.py @@ -0,0 +1,51 @@ +"""Renderer that outputs formatted messaging for end users.""" +import sys + +from termcolor import colored + +from testtube.conf import Settings + + +class Renderer(object): + + """Utility that outputs formatted messages.""" + + def failure(self, message=''): + """Print the passed message in red.""" + print colored(message, 'red') + + def notice(self, message=''): + """Print the passed message.""" + print message + + def success(self, message=''): + """Print the passed message in green.""" + print colored(message, 'green') + + def divider(self): + """Print a divider.""" + print '=' * 71 + + def report(self, results): + """Print a test report.""" + self.notice("Test Report\n") + + for count, group in enumerate(results, 1): + results = (', ').join( + self._format_test(test, res) for test, res in group) + self.notice("Test group %s:\t%s" % (count, results)) + + self.divider() + + def audible_alert(self, count): + """Beep the number of times specified.""" + sys.stdout.write('\a' * count) + + def _format_test(self, test, result): + color = 'green' if result else 'red' + files = '' + + if not test.all_files: + files = ' (%s)' % Settings.short_path(test.changed) + + return colored('%s%s' % (test.name, files), color) diff --git a/testtube/runner.py b/testtube/runner.py index 89d1b94..5f43798 100644 --- a/testtube/runner.py +++ b/testtube/runner.py @@ -1,10 +1,9 @@ """Utilities for executing testtube suites against changed files.""" import re -from termcolor import colored - from testtube.conf import Settings from testtube.helpers import HardTestFailure +from testtube.renderer import Renderer class ResultCollection(list): @@ -26,8 +25,6 @@ class TestCollection(object): """Pattern, test list, and config grouping.""" - fail_fast_msg = colored('Fail fast is enabled. Aborting tests.', 'red') - def __init__(self, pattern, tests, conf=None): """Build a test collection given a regex, a test list and configuration. @@ -92,55 +89,31 @@ def _check_path(self, path): class SuiteRunner(object): - fail_fast_msg = colored( - 'Aborting subsequent test groups. Fail fast enabled.', 'red') - test_divider = '=' * 71 + + """Execute matching test groups against a given path.""" + + renderer = Renderer() def run(self, path): + """Execute matching test groups against a given path.""" results = [] for test_group in Settings.PATTERNS: tests = TestCollection(*test_group) result = tests.apply(path) - if result: - results.append(result) - - if result and not result.passed and tests.fail_fast: - self._render_fail_fast_error() - self._render_divider() - break - - if result: - self._render_divider() - - if results: - self._render_test_report(results) - - def _render_divider(self): - print self.test_divider - - def _render_fail_fast_error(self): - print self.fail_fast_msg - - def _render_test_report(self, results): - if not results: - return - - print "Test Report\n" - - for count, suite_group in enumerate(results, 1): - if not suite_group: + if not result: continue - test_group_results = "Test group %s:\t" % count + results.append(result) - for test, result in suite_group: - color = 'green' if result else 'red' - files = test.changed if not test.all_files else 'all matches' - test_group_results += colored( - '%s (%s)\t' % (test.name, files), color) + if not result.passed and tests.fail_fast: + self.renderer.failure( + 'Aborting subsequent test groups. Fail fast enabled.') + self.renderer.divider() + break - print test_group_results + self.renderer.divider() - self._render_divider() + if results: + self.renderer.report(results) From 58f2036e523f4b759ac24e15db9d42f7a873cb40 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 29 Jun 2014 11:18:27 -0700 Subject: [PATCH 15/37] setup.py: add termcolor to install_requires --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4f040d2..b34551d 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ 'a change occurs.', packages=find_packages(), scripts=['testtube/bin/stir'], - install_requires=['watchdog==0.7.1'], + install_requires=['termcolor==1.1.0', 'watchdog==0.7.1'], classifiers=[ 'Intended Audience :: Developers', 'Topic :: Software Development :: Testing', From 113ff9569d2eed797b39f7c75da5b1a045f6492e Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 29 Jun 2014 11:19:24 -0700 Subject: [PATCH 16/37] setup.py: classify testtube for python 3.4, not 3.3 This also changes the environment we test against from 3.3 to 3.4. --- .travis.yml | 6 +++--- setup.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index eb93155..e2ed8ef 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,11 +2,11 @@ language: python python: - "2.6" - "2.7" - - "3.3" + - "3.4" - "pypy" install: - - if [[ $TRAVIS_PYTHON_VERSION == '3.3' ]]; then pip install -r py3_requirements.txt; fi - - if [[ $TRAVIS_PYTHON_VERSION != '3.3' ]]; then pip install -r requirements.txt; fi + - if [[ $TRAVIS_PYTHON_VERSION == '3.4' ]]; then pip install -r py3_requirements.txt; fi + - if [[ $TRAVIS_PYTHON_VERSION != '3.4' ]]; then pip install -r requirements.txt; fi script: - nosetests --with-coverage --cover-package=testtube after_success: diff --git a/setup.py b/setup.py index b34551d..d14feba 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: Implementation :: PyPy', ], test_suite='nose.collector', From 4081c552f24aee08e311e1704a22068a73b02960 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 29 Jun 2014 11:19:40 -0700 Subject: [PATCH 17/37] Alphabetize requirements --- requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 423ba2c..293350f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,12 @@ -watchdog==0.7.1 termcolor==1.1.0 +watchdog==0.7.1 # Testing requirements coveralls==0.4.1 -pinocchio==0.4.1 flake8==2.1.0 frosted==1.4.1 mock==1.0.1 -pep257==0.3.2 nose==1.3.1 +pep257==0.3.2 +pinocchio==0.4.1 unittest2==0.5.1 From 3b5f44446ff83522368761d0b019ba0a858f115c Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 29 Jun 2014 11:20:27 -0700 Subject: [PATCH 18/37] Bring py3 requirements file back up to speed --- py3_requirements.txt | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/py3_requirements.txt b/py3_requirements.txt index 8a49341..5a5ea53 100644 --- a/py3_requirements.txt +++ b/py3_requirements.txt @@ -1,11 +1,10 @@ +termcolor==1.1.0 watchdog==0.7.1 # Testing requirements coveralls==0.4.1 -pep8==1.3.3 -pinocchio==0.4.1 -frosted==1.4.0 +flake8==2.1.0 +frosted==1.4.1 nose==1.3.1 - -# Helper requirements -pyflakes==0.5.0 +pep257==0.3.2 +pinocchio==0.4.1 From 577c21472c057938edb7f5d64af4ed3ce252e65e Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 29 Jun 2014 11:30:26 -0700 Subject: [PATCH 19/37] Renderer: make print statements python 3 compatible. --- testtube/renderer.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/testtube/renderer.py b/testtube/renderer.py index 95d7cdf..9d228bb 100644 --- a/testtube/renderer.py +++ b/testtube/renderer.py @@ -5,6 +5,8 @@ from testtube.conf import Settings +# For python3 support, we must use print as a function rather than a statement + class Renderer(object): @@ -12,19 +14,19 @@ class Renderer(object): def failure(self, message=''): """Print the passed message in red.""" - print colored(message, 'red') + print(colored(message, 'red')) def notice(self, message=''): """Print the passed message.""" - print message + print(message) def success(self, message=''): """Print the passed message in green.""" - print colored(message, 'green') + print(colored(message, 'green')) def divider(self): """Print a divider.""" - print '=' * 71 + print('=' * 71) def report(self, results): """Print a test report.""" From 86c374ba0d2ab1564aecb9a9b640b2a90c6f5a4c Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 29 Jun 2014 11:55:02 -0700 Subject: [PATCH 20/37] conf: Cleanup docstrings and test formatting --- testtube/conf.py | 13 ++++++++----- testtube/tests/test_conf.py | 6 +++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/testtube/conf.py b/testtube/conf.py index 9c667c2..eee000c 100644 --- a/testtube/conf.py +++ b/testtube/conf.py @@ -5,6 +5,7 @@ class Settings(object): + """Testtube settings module.""" # testube default settings CWD_SRC_DIR = '' SRC_DIR = os.getcwd() @@ -12,7 +13,7 @@ class Settings(object): @classmethod def configure(cls, src_dir, settings): - """Configures testtube to use a src directory and settings module.""" + """Configure testtube to use a src directory and settings module.""" cls.CWD_SRC_DIR = src_dir cls.SRC_DIR = os.path.realpath( os.path.join(os.getcwd(), cls.CWD_SRC_DIR)) @@ -20,20 +21,22 @@ def configure(cls, src_dir, settings): @classmethod def get_settings(cls, settings_module): - """Set conf attributes equal to all uppercase attributes of settings""" + """Set class attributes equal to uppercased attributes of a module.""" + print settings_module, os.path.join(os.getcwd(), settings_module) settings = imp.load_source( 'settings', os.path.join(os.getcwd(), settings_module)) - cls.PATTERNS = settings.PATTERNS + for setting in (x for x in dir(settings) if x.isupper()): + setattr(cls, setting, getattr(settings, setting)) @classmethod def short_path(cls, path): - """Removes conf.SRC_DIR from a given path.""" + """Remove conf.SRC_DIR from a given path.""" return path.partition("%s%s" % (cls.SRC_DIR, '/'))[2] def get_arguments(): - """Prompts user for a source directory and an optional settings module.""" + """Prompt user for a source directory and an optional settings module.""" parser = argparse.ArgumentParser( description='Watch a directory and run a custom set of tests whenever' ' a file changes.') diff --git a/testtube/tests/test_conf.py b/testtube/tests/test_conf.py index ae0de20..a499edb 100644 --- a/testtube/tests/test_conf.py +++ b/testtube/tests/test_conf.py @@ -18,12 +18,12 @@ def test_should_set_the_SRC_DIR(self): self.assertEqual( self.settings.SRC_DIR, os.path.join(os.getcwd(), 'foo')) - def test_should_set_PATTERNS_to_setting_modules_PATTERNS_property(self): + def test_should_import_uppercased_settings_from_settings_module(self): self.assertEqual(self.settings.PATTERNS, self.settings.PATTERNS) -class Shortpath(ConfTestCase): - """Settings' short_path() method""" +class SettingsModuleShortpathMethod(ConfTestCase): + """Settings.short_path()""" def test_removes_SRC_DIR_from_the_passed_path(self): """removes Settings.SRC_DIR from the passed path""" sample_file = os.path.join(os.getcwd(), 'foo/sample.py') From bf317aa171b34f43584b1ee75a55706a344d052e Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 29 Jun 2014 12:03:34 -0700 Subject: [PATCH 21/37] conf: Add test for get_arguments --- testtube/tests/test_conf.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/testtube/tests/test_conf.py b/testtube/tests/test_conf.py index a499edb..b380b8c 100644 --- a/testtube/tests/test_conf.py +++ b/testtube/tests/test_conf.py @@ -1,6 +1,6 @@ import os -from testtube.conf import Settings +from testtube.conf import Settings, get_arguments from . import test_settings, unittest @@ -28,3 +28,16 @@ def test_removes_SRC_DIR_from_the_passed_path(self): """removes Settings.SRC_DIR from the passed path""" sample_file = os.path.join(os.getcwd(), 'foo/sample.py') self.assertEqual(self.settings.short_path(sample_file), 'sample.py') + + +class GetArguments(unittest.TestCase): + """get_arguments()""" + def setUp(self): + self.args = get_arguments() + self.default_path, self.default_settings_module = self.args + + def test_returns_thew_cwd_as_the_default_path(self): + self.assertEqual(self.default_path, os.getcwd()) + + def test_returns_tube_dot_py_as_the_default_settings_module_name(self): + self.assertEqual(self.default_settings_module, 'tube.py') From abd5d2994379ea20df312d9a842f476acd139fc5 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 29 Jun 2014 17:40:55 -0700 Subject: [PATCH 22/37] Add renderer tests. --- testtube/renderer.py | 4 +- testtube/tests/__init__.py | 4 +- testtube/tests/test_renderer.py | 95 +++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 testtube/tests/test_renderer.py diff --git a/testtube/renderer.py b/testtube/renderer.py index 9d228bb..dc3ce17 100644 --- a/testtube/renderer.py +++ b/testtube/renderer.py @@ -33,8 +33,8 @@ def report(self, results): self.notice("Test Report\n") for count, group in enumerate(results, 1): - results = (', ').join( - self._format_test(test, res) for test, res in group) + results = (self._format_test(test, res) for test, res in group) + results = (', ').join(results) self.notice("Test group %s:\t%s" % (count, results)) self.divider() diff --git a/testtube/tests/__init__.py b/testtube/tests/__init__.py index 92d6bcd..f206dd0 100644 --- a/testtube/tests/__init__.py +++ b/testtube/tests/__init__.py @@ -6,6 +6,6 @@ import unittest # NOQA if sys.version_info < (3,): - from mock import Mock, patch, ANY # NOQA + from mock import call, Mock, patch, ANY # NOQA else: - from unittest.mock import Mock, patch, ANY # NOQA + from unittest.mock import call, Mock, patch, ANY # NOQA diff --git a/testtube/tests/test_renderer.py b/testtube/tests/test_renderer.py new file mode 100644 index 0000000..7d7d2c4 --- /dev/null +++ b/testtube/tests/test_renderer.py @@ -0,0 +1,95 @@ +import sys + +from . import call, Mock, patch, unittest + +from termcolor import colored + +from testtube.renderer import Renderer + + +class RendererTest(unittest.TestCase): + def setUp(self): + # use self.actual_stdout.write() for debugging. Print won't work + # while the unit is under test as a resut of the stdout patching + # below. + self.actual_stdout = sys.stdout + + stdout_patch = patch('sys.stdout') + self.stdout = stdout_patch.start() + self.addCleanup(stdout_patch.stop) + + self.renderer = Renderer() + + +class RendererNotice(RendererTest): + def setUp(self): + super(RendererNotice, self).setUp() + self.renderer.notice('yay') + + def test_prints_passed_message(self): + self.stdout.write.assert_has_calls([call('yay'), call('\n')]) + + +class RendererFailure(RendererTest): + def setUp(self): + super(RendererFailure, self).setUp() + self.renderer.failure('error message') + self.red_error = colored('error message', 'red') + + def test_prints_message_in_red(self): + self.stdout.write.assert_has_calls([call(self.red_error), call('\n')]) + + +class RendererSuccess(RendererTest): + def setUp(self): + super(RendererSuccess, self).setUp() + self.renderer.success('sucess message') + self.green_success = colored('sucess message', 'green') + + def test_prints_message_in_green(self): + self.stdout.write.assert_has_calls( + [call(self.green_success), call('\n')]) + + +class RendererAudibleAlert(RendererTest): + def setUp(self): + super(RendererAudibleAlert, self).setUp() + self.renderer.audible_alert(40) + + def test_triggers_the_specified_number_of_bells(self): + self.stdout.write.assert_called_with('\a' * 40) + + +class RendererDivider(RendererTest): + def setUp(self): + super(RendererDivider, self).setUp() + self.renderer.divider() + + def test_prints_a_divider_71_characters_wide(self): + self.stdout.write.assert_has_calls([call('=' * 71), call('\n')]) + + +class RendererReport(RendererTest): + def setUp(self): + super(RendererReport, self).setUp() + self.fake_test = Mock() + self.fake_test.name = 'FakeTest' + self.fake_test.changed = 'foo.py' + self.renderer.report([ + [(self.fake_test, True), (self.fake_test, False)], + [(self.fake_test, False), (self.fake_test, False)] + ]) + + def test_outputs_color_coded_test_results(self): + success_name = colored('FakeTest', 'green') + fail_name = colored('FakeTest', 'red') + self.stdout.write.assert_has_calls([ + call('Test Report\n'), + call('\n'), + call('Test group 1:\t%s, %s' % (success_name, fail_name)), + call('\n'), + call('Test group 2:\t%s, %s' % (fail_name, fail_name)), + call('\n'), + call('=' * 71), + call('\n') + ]) From 0f27390ae720376c817d9f0f1517e34288c1564e Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 29 Jun 2014 21:07:04 -0700 Subject: [PATCH 23/37] conf tests: stub sys.argv and restore it Not doing this was causing nosetests to blow up if it it was passed parameters. --- testtube/tests/test_conf.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/testtube/tests/test_conf.py b/testtube/tests/test_conf.py index b380b8c..17627d9 100644 --- a/testtube/tests/test_conf.py +++ b/testtube/tests/test_conf.py @@ -1,4 +1,5 @@ import os +import sys from testtube.conf import Settings, get_arguments @@ -33,9 +34,14 @@ def test_removes_SRC_DIR_from_the_passed_path(self): class GetArguments(unittest.TestCase): """get_arguments()""" def setUp(self): + self.argv = sys.argv + sys.argv = [''] self.args = get_arguments() self.default_path, self.default_settings_module = self.args + def tearDown(self): + sys.argv = self.argv + def test_returns_thew_cwd_as_the_default_path(self): self.assertEqual(self.default_path, os.getcwd()) From 385e40a982ee2753831ccddfa0f0a3a3b293f44b Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 29 Jun 2014 21:09:45 -0700 Subject: [PATCH 24/37] Remove leftover debugging code --- testtube/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/testtube/conf.py b/testtube/conf.py index eee000c..9e97b68 100644 --- a/testtube/conf.py +++ b/testtube/conf.py @@ -22,7 +22,6 @@ def configure(cls, src_dir, settings): @classmethod def get_settings(cls, settings_module): """Set class attributes equal to uppercased attributes of a module.""" - print settings_module, os.path.join(os.getcwd(), settings_module) settings = imp.load_source( 'settings', os.path.join(os.getcwd(), settings_module)) From 1098d8614b46b1d31f2bbb3d07c32f624e4075b6 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 5 Oct 2014 13:47:13 -0700 Subject: [PATCH 25/37] Add coverage output to tests --- .coveragerc | 6 ++++++ requirements.txt | 3 ++- setup.cfg | 4 +++- testtube/conf.py | 2 ++ tube.py | 4 ++-- 5 files changed, 15 insertions(+), 4 deletions(-) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..c54b229 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +[report] +omit = + setup.py + tube.py + testtube/tests/* + */site-packages/* diff --git a/requirements.txt b/requirements.txt index 293350f..ec6e644 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,11 +2,12 @@ termcolor==1.1.0 watchdog==0.7.1 # Testing requirements +coverage==3.7.1 coveralls==0.4.1 flake8==2.1.0 frosted==1.4.1 mock==1.0.1 -nose==1.3.1 +nose==1.3.4 pep257==0.3.2 pinocchio==0.4.1 unittest2==0.5.1 diff --git a/setup.cfg b/setup.cfg index 2b4351c..7a2c877 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,5 @@ [nosetests] with-spec=1 -spec-color=1 \ No newline at end of file +spec-color=1 +with-coverage=1 +cover-package=testtube diff --git a/testtube/conf.py b/testtube/conf.py index 9e97b68..1bdd716 100644 --- a/testtube/conf.py +++ b/testtube/conf.py @@ -5,7 +5,9 @@ class Settings(object): + """Testtube settings module.""" + # testube default settings CWD_SRC_DIR = '' SRC_DIR = os.getcwd() diff --git a/tube.py b/tube.py index bf8d656..057bb62 100644 --- a/tube.py +++ b/tube.py @@ -17,9 +17,9 @@ [Flake8(all_files=True), Frosted(all_files=True)], {'fail_fast': True} ), - # Run the test suite whenever python files change + # Run the test suite whenever python or test config files change ( - r'.*\.py$', + r'(.*setup\.cfg$)|(.*\.coveragerc)|(.*\.py$)', [Nosetests()] ) ) From 948eff4c7878dc1e15a06f150c9c7dab1b637cd6 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 5 Oct 2014 14:09:23 -0700 Subject: [PATCH 26/37] Test that renderer prints single file tests okay --- testtube/tests/test_renderer.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/testtube/tests/test_renderer.py b/testtube/tests/test_renderer.py index 7d7d2c4..004b221 100644 --- a/testtube/tests/test_renderer.py +++ b/testtube/tests/test_renderer.py @@ -93,3 +93,30 @@ def test_outputs_color_coded_test_results(self): call('=' * 71), call('\n') ]) + + +class SingleFilesRendererReport(RendererTest): + def setUp(self): + super(SingleFilesRendererReport, self).setUp() + self.fake_test = Mock() + self.fake_test.name = 'FakeTest' + self.fake_test.changed = 'foo.py' + self.fake_test.all_files = False + self.renderer.report([ + [(self.fake_test, True), (self.fake_test, False)], + [(self.fake_test, False), (self.fake_test, False)] + ]) + + def test_outputs_color_coded_test_results_with_short_path(self): + success_name = colored('FakeTest ()', 'green') + fail_name = colored('FakeTest ()', 'red') + self.stdout.write.assert_has_calls([ + call('Test Report\n'), + call('\n'), + call('Test group 1:\t%s, %s' % (success_name, fail_name)), + call('\n'), + call('Test group 2:\t%s, %s' % (fail_name, fail_name)), + call('\n'), + call('=' * 71), + call('\n') + ]) From e5d3aecf10d767afc02adbe1f69b024d80003332 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 5 Oct 2014 17:41:04 -0700 Subject: [PATCH 27/37] Fix bug that was preventing fail fast from working This also adds exhaustive tests for the classes in runner.py --- testtube/runner.py | 2 +- testtube/tests/test_runner.py | 143 ++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 testtube/tests/test_runner.py diff --git a/testtube/runner.py b/testtube/runner.py index 5f43798..bdc8ad8 100644 --- a/testtube/runner.py +++ b/testtube/runner.py @@ -51,7 +51,7 @@ def fail_fast(self): tests in their group fail. """ - self.conf.get('fail_fast', False) + return self.conf.get('fail_fast', False) def apply(self, path): """Run tests against a path if it matches the configured pattern. diff --git a/testtube/tests/test_runner.py b/testtube/tests/test_runner.py new file mode 100644 index 0000000..6a50f78 --- /dev/null +++ b/testtube/tests/test_runner.py @@ -0,0 +1,143 @@ +from . import ANY, Mock, patch, unittest + +from testtube.helpers import HardTestFailure +from testtube.runner import ResultCollection, TestCollection, SuiteRunner + + +class ResultCollections(unittest.TestCase): + """ResultCollections""" + def setUp(self): + self.passed_tests = ResultCollection([(Mock(), True), (Mock(), True)]) + self.mixed_result_tests = ResultCollection( + [(Mock(), True), (Mock(), False)]) + + def test_are_an_iterable(self): + iter(self.passed_tests) + + def test_passed_property_is_true_if_all_tests_passed(self): + self.assertTrue(self.passed_tests.passed) + + def test_passed_property_is_false_if_any_test_results_indicate_fail(self): + self.assertFalse(self.mixed_result_tests.passed) + + +class TestCollectionTests(unittest.TestCase): + def setUp(self): + self.fake_passing_test = Mock() + self.fake_passing_test.return_value = True + self.fake_failing_test = Mock() + self.fake_failing_test.return_value = False + self.tests = [self.fake_passing_test, self.fake_failing_test] + + self.test_collection = TestCollection( + r'.*', self.tests, {'fail_fast': True}) + + +class TestCollections(TestCollectionTests): + """TestCollections""" + def test_instation_sets_conf_attribute(self): + self.assertEqual(self.test_collection.conf, {'fail_fast': True}) + + def test_instantiation_sets_pattern_attribute(self): + self.assertEqual(self.test_collection.pattern, r'.*') + + def test_instantiation_sets_tests_attribute(self): + self.assertEqual(self.test_collection.tests, self.tests) + + def test_fail_fast_attribute_returns_true_when_configured_to_true(self): + """fail_fast attribute returns True when configured that way""" + self.assertTrue(self.test_collection.fail_fast) + + +class TestCollectionApplyWhenPatternMatchesAgainstPath(TestCollectionTests): + """TestCollection.apply() when pattern matches passed path""" + def setUp(self): + super(TestCollectionApplyWhenPatternMatchesAgainstPath, self).setUp() + + self.results = self.test_collection.apply('myfile.py') + + def test_runs_all_tests(self): + for test in self.tests: + test.assert_called_once_with('myfile.py', ANY) + + def test_returns_result_collection_with_entries_for_all_tests(self): + self.assertEqual( + self.results, + [(self.fake_passing_test, True), (self.fake_failing_test, False)]) + + +class TestCollectionApplyIfPatternDoesntMatchAgainstPath(TestCollectionTests): + """TestCollection.apply() if pattern doesn't match against passed path""" + def setUp(self): + super(TestCollectionApplyIfPatternDoesntMatchAgainstPath, self).setUp() + + # Reconfigure the test collection to only run if the passed file is + # foo.py + self.test_collection.pattern = r'foo\.py' + + self.results = self.test_collection.apply('myfile.py') + + def test_returns_empty_result_collection(self): + self.assertEqual(self.results, ResultCollection()) + + def test_doesnt_execute_tests(self): + for test in self.tests: + self.assertFalse(test.called) + + +class TestCollectionApplyIfTestHasHardTestFailure(TestCollectionTests): + """TestCollection.apply() if a test raises a HardTestFailure""" + def setUp(self): + super(TestCollectionApplyIfTestHasHardTestFailure, self).setUp() + + # Add a test that raises a HardTestFailure to the collection + self.hard_test_failure = Mock() + self.hard_test_failure.side_effect = HardTestFailure('failing') + self.tests = [self.hard_test_failure] + self.tests + + self.test_collection.tests = self.tests + + self.results = self.test_collection.apply('myfile.py') + + def test_doesnt_execute_tests_after_the_raising_test(self): + """doesn't execute tests after the raising test""" + self.assertTrue(self.hard_test_failure.called) + self.assertFalse(self.fake_passing_test.called) + self.assertFalse(self.fake_failing_test.called) + + def test_returns_a_result_collection_with_raisig_test_as_failure(self): + self.assertEqual(self.results, [(self.hard_test_failure, False)]) + + +class SuiteRunnerTests(unittest.TestCase): + """SuiteRunner.run()""" + def setUp(self): + self.test1, self.test2, self.test3 = Mock(), Mock(), Mock() + self.test1.return_value = True + self.test2.return_value = True + self.test3.return_value = False + + SuiteRunner.renderer = Mock() + + settings_patcher = patch('testtube.runner.Settings') + self.addCleanup(settings_patcher.stop) + self.Settings = settings_patcher.start() + self.Settings.PATTERNS = [ + (r'.*', [self.test1, self.test2]), + (r'.*', [self.test3], {'fail_fast': True}), + (r'.*', []) + + ] + + self.runner = SuiteRunner() + self.runner.run('yay.py') + + def test_renders_a_result_report_with_list_of_result_groupings(self): + self.runner.renderer.report.assert_called_once_with([ + [(self.test1, True), (self.test2, True)], + [(self.test3, False)] + ]) + + def test_outputs_fail_fast_messaging_if_ff_test_group_fails(self): + self.runner.renderer.failure.assert_called_once_with( + 'Aborting subsequent test groups. Fail fast enabled.') From 7d0024675f6b95aeff3f416c47668378055fae93 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 5 Oct 2014 22:33:10 -0700 Subject: [PATCH 28/37] Make helpers correctly state the files they check all_files helpers were stating that they were checking individual files when they were in fact checking the entire source directory. single file helpers were stating that they were checking the entire directory when they were only checking a single file. This fixes that probelm and adds ehxaustive helper tests. --- testtube/helpers.py | 2 +- testtube/tests/test_helpers.py | 127 +++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/testtube/helpers.py b/testtube/helpers.py index 840e4d9..db6503f 100644 --- a/testtube/helpers.py +++ b/testtube/helpers.py @@ -27,7 +27,7 @@ def __init__(self, **kwargs): self.match = '' def setup(self): - changed = "all matching files" if not self.all_files else self.changed + changed = "all matching files" if self.all_files else self.changed self.renderer.notice( 'Executing %s against %s.\n' % (self.name, changed)) diff --git a/testtube/tests/test_helpers.py b/testtube/tests/test_helpers.py index e69de29..6508d7c 100644 --- a/testtube/tests/test_helpers.py +++ b/testtube/tests/test_helpers.py @@ -0,0 +1,127 @@ +from . import Mock, patch, unittest + +from testtube.helpers import ( + Frosted, HardTestFailure, Nosetests, Pep8, PythonSetupPyTest) + + +class HelperTests(unittest.TestCase): + helper_conf = {} + helper_class = Pep8 + subprocess_result = 0 + src_dir = '/fake/path' + test_path = 'fake_path.py' + execute_test = True + + def setUp(self): + subproc_patcher = patch('testtube.helpers.subprocess') + self.addCleanup(subproc_patcher.stop) + self.subprocess = subproc_patcher.start() + self.subprocess.call.return_value = self.subprocess_result + + settings_patcher = patch('testtube.helpers.Settings') + self.addCleanup(settings_patcher.stop) + self.settings = settings_patcher.start() + self.settings.SRC_DIR = self.src_dir + + self.renderer = Mock() + + self.helper = self.helper_class(**self.helper_conf) + self.helper.renderer = self.renderer + self.fake_match_obj = Mock() + + self.result = None + + if self.execute_test: + self.result = self.helper('fake_path.py', self.fake_match_obj) + + +class Pep8Helper(HelperTests): + def test_invokes_the_pep8_command_against_a_specified_path(self): + self.subprocess.call.assert_called_once_with(['pep8', 'fake_path.py']) + + def test_outputs_testing_notice(self): + self.renderer.notice.assert_any_call( + 'Executing Pep8 against fake_path.py.\n') + + def test_outputs_success_message_if_tests_pass(self): + self.renderer.success.assert_called_once_with('Test passed.') + + def test_returns_true_if_tests_pass(self): + self.assertTrue(self.result) + + +class Pep8HelperOnTestFailure(HelperTests): + subprocess_result = 1 + + def test_audibly_rings(self): + self.renderer.audible_alert.assert_called_once_with(3) + + def test_outputs_failure_notice(self): + self.renderer.failure.assert_called_once_with('Test failed.') + + +class Pep8HelperOnTestFailureWithFailFastEnabled(HelperTests): + helper_conf = {'fail_fast': True} + subprocess_result = 1 + execute_test = False + + def test_raises_HardTestFailure(self): + self.assertRaises( + HardTestFailure, self.helper, 'fake_path.py', self.fake_match_obj) + + +class Pep8HelperWithInvalidTestCommand(HelperTests): + helper_conf = {'command': None} + + def test_doesnt_invoke_a_subprocess(self): + self.assertFalse(self.subprocess.call.called) + + def test_passes(self): + self.assertTrue(self.result) + + +class Pep8HelperConfiguredToCheckEntireSrcDir(HelperTests): + helper_conf = {'all_files': True} + + def test_runs_pep8_againts_entire_src_dir(self): + self.subprocess.call.assert_called_once_with(['pep8', '/fake/path']) + + def test_outputs_test_notice_without_specific_path(self): + self.renderer.notice.assert_any_call( + 'Executing Pep8 against all matching files.\n') + + +class FrostedHelperConfiguredToCheckAllFiles(HelperTests): + helper_class = Frosted + helper_conf = {'all_files': True} + + def test_adds_r_flag_when_passing_settings_dir_to_frosted_cmd(self): + """adds -r flag when passing settings dir to frosted""" + self.subprocess.call.assert_called_once_with( + ['frosted', '-r', '/fake/path']) + + +class FrotedHelperNotConfiguredToCheckAllFiles(HelperTests): + helper_class = Frosted + + def test_only_checks_the_changed_file_and_doesnt_use_any_flags(self): + self.subprocess.call.assert_called_once_with( + ['frosted', 'fake_path.py']) + + +class NosetestsHelper(HelperTests): + helper_conf = {'all_files': False} + helper_class = Nosetests + + def test_doesnt_allow_itself_to_be_configured_to_check_single_files(self): + self.assertTrue(self.helper.all_files) + + +class PythonSetupPyHelper(HelperTests): + """PythonSetupPy helper""" + helper_class = PythonSetupPyTest + + def test_always_uses_setup_py_test_as_args(self): + """always uses 'setup.py test' as args""" + self.subprocess.call.assert_called_once_with( + ['python', 'setup.py', 'test']) From 89a150ff7dea5003ac820c9a9fa05a84ff896c68 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 5 Oct 2014 22:46:00 -0700 Subject: [PATCH 29/37] Don't use iteritems directly, it's gone in py3 This installs the compatibility library six and uses iteritems from that instead. --- requirements.txt | 1 + setup.py | 2 +- testtube/helpers.py | 4 +++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index ec6e644..93625bc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,4 @@ nose==1.3.4 pep257==0.3.2 pinocchio==0.4.1 unittest2==0.5.1 +six>=1.2.0 diff --git a/setup.py b/setup.py index d14feba..e0ad2f9 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ 'a change occurs.', packages=find_packages(), scripts=['testtube/bin/stir'], - install_requires=['termcolor==1.1.0', 'watchdog==0.7.1'], + install_requires=['six>=1.2.0', 'termcolor==1.1.0', 'watchdog==0.7.1'], classifiers=[ 'Intended Audience :: Developers', 'Topic :: Software Development :: Testing', diff --git a/testtube/helpers.py b/testtube/helpers.py index db6503f..c7872a4 100644 --- a/testtube/helpers.py +++ b/testtube/helpers.py @@ -1,5 +1,7 @@ import subprocess +from six import iteritems + from testtube.conf import Settings from testtube.renderer import Renderer @@ -19,7 +21,7 @@ def __init__(self, **kwargs): self.name = self.__class__.__name__ # Override default settings with passed in values. - for setting, value in kwargs.iteritems(): + for setting, value in iteritems(kwargs): setattr(self, setting, value) # These properites are provided by __call__ and are not configurable. From a2e11af01c7338b4c9f99ec279ac70bc6ed2ebad Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 5 Oct 2014 23:44:08 -0700 Subject: [PATCH 30/37] Add docstrings --- testtube/handlers.py | 1 + testtube/helpers.py | 78 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/testtube/handlers.py b/testtube/handlers.py index 95c171a..e3eb28b 100644 --- a/testtube/handlers.py +++ b/testtube/handlers.py @@ -1,3 +1,4 @@ +"""Handlers that are invoked when file system changes occur.""" from watchdog.events import FileSystemEventHandler from testtube.runner import SuiteRunner diff --git a/testtube/helpers.py b/testtube/helpers.py index c7872a4..95d461f 100644 --- a/testtube/helpers.py +++ b/testtube/helpers.py @@ -1,3 +1,4 @@ +"""Callables that accept a path to a file and check it for various things.""" import subprocess from six import iteritems @@ -7,14 +8,46 @@ class HardTestFailure(Exception): + + """Test failure that should abort test processing.""" + pass class Helper(object): + + """Generic helper class for writing callable testtube tests. + + When a test that extends Helper is instantiated and called, it will: + + 1. Set self.changed (path to changed file) and self.match + (regex match object) to the passed in values. + 2. Call self.setup() + 3. Set result = self.test() + 4. If the test passed, it will call self.success(result) + 5. If the test failed, it will call self.failure(result) + 6. Call self.tear_down(result) + 7. Return the result + + self.test(), called in step 3, invokves the class attribute `command` via + subporcess.call() and passes that command the arguments returned by + get_args(). + + Any method in the execution squence is overridable to enable helpers to be + customized as necessary. See Helper subclasses for examples. + + """ + command = '' renderer = Renderer() def __init__(self, **kwargs): + """Configure and return callable test. + + All keword arguments except `changed` and `match` are set as object + attributes. + + """ self.all_files = False self.fail_fast = False self.bells = 3 @@ -29,17 +62,21 @@ def __init__(self, **kwargs): self.match = '' def setup(self): + """Prepare the helper class to execute the test.""" changed = "all matching files" if self.all_files else self.changed self.renderer.notice( 'Executing %s against %s.\n' % (self.name, changed)) def tear_down(self, result): + """Clean up test execution.""" self.renderer.notice() def success(self, result): + """Handle test success.""" self.renderer.success('Test passed.') def failure(self, result): + """Hanlde test failure.""" self.renderer.audible_alert(self.bells) self.renderer.failure('Test failed.') @@ -47,18 +84,21 @@ def failure(self, result): raise HardTestFailure('Fail fast is enabled, aborting test run.') def test(self): + """Execute the configured command with appropriate arguments.""" if not self.command: return True return subprocess.call([self.command] + self.get_args()) == 0 def get_args(self): + """Generate argumnets for the test process.""" if self.all_files: return [Settings.SRC_DIR] return [self.changed] def __call__(self, changed, match): + """Test a changed file with the configured command.""" self.changed = changed self.match = match @@ -78,17 +118,27 @@ def __call__(self, changed, match): class Pep8(Helper): + + """Execute PEP8 against a file or configured project directory.""" + command = 'pep8' class Pyflakes(Helper): + + """Execute pyflakes against a file or configured project directory.""" + command = 'pyflakes' class Frosted(Helper): + + """Execute pyflakes against a file or configured project directory.""" + command = 'frosted' def get_args(self): + """ Generate frosted arguments.""" if self.all_files: return ['-r', Settings.SRC_DIR] @@ -96,9 +146,22 @@ def get_args(self): class Nosetests(Helper): + + """Execute nosetests in the configured project directory. + + Note that this helper cannot be configured to run against only the + changed file. + + """ + command = 'nosetests' def __init__(self, **kwargs): + """Generate a `nosetests` callable. + + all_files=False will be ignored. + + """ super(Nosetests, self).__init__() # Nosetests only works on all files, so override any config for this @@ -106,19 +169,34 @@ def __init__(self, **kwargs): self.all_files = True def get_args(self, *args, **kwargs): + """Return empty list of arguments. + + Nose can be configured via a setup.cfg file in settings.SRC_DIR + + """ return [] class Flake8(Helper): + + """Execute flake8 against a file or configured project directory.""" + command = 'flake8' class Pep257(Helper): + + """Execute pep257 against a file or configured project directory.""" + command = 'pep257' class PythonSetupPyTest(Helper): + + """Execute `python setup.py test`.""" + command = 'python' def get_args(self): + """Return list of arguments for `python`.""" return ['setup.py', 'test'] From 971f91b7e044d8ab753f63260a3cd8ad5b44ded5 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Sun, 5 Oct 2014 23:49:22 -0700 Subject: [PATCH 31/37] sync py3 requirements up with py2 requirements --- py3_requirements.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/py3_requirements.txt b/py3_requirements.txt index 5a5ea53..13c4b46 100644 --- a/py3_requirements.txt +++ b/py3_requirements.txt @@ -2,9 +2,11 @@ termcolor==1.1.0 watchdog==0.7.1 # Testing requirements +coverage==3.7.1 coveralls==0.4.1 flake8==2.1.0 frosted==1.4.1 -nose==1.3.1 +nose==1.3.4 pep257==0.3.2 pinocchio==0.4.1 +six>=1.2.0 From 5cff456d07a5626c3fd4b2e7d4984bb019dca91c Mon Sep 17 00:00:00 2001 From: Thomas W Date: Mon, 6 Oct 2014 00:38:03 -0700 Subject: [PATCH 32/37] Correct all_files helper pre-test notices It used to say, "Executing against all matching files.", but it was really "Executing against source directory." --- testtube/helpers.py | 2 +- testtube/tests/test_helpers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/testtube/helpers.py b/testtube/helpers.py index 95d461f..a0655f9 100644 --- a/testtube/helpers.py +++ b/testtube/helpers.py @@ -63,7 +63,7 @@ def __init__(self, **kwargs): def setup(self): """Prepare the helper class to execute the test.""" - changed = "all matching files" if self.all_files else self.changed + changed = "source directory" if self.all_files else self.changed self.renderer.notice( 'Executing %s against %s.\n' % (self.name, changed)) diff --git a/testtube/tests/test_helpers.py b/testtube/tests/test_helpers.py index 6508d7c..1f4cb56 100644 --- a/testtube/tests/test_helpers.py +++ b/testtube/tests/test_helpers.py @@ -88,7 +88,7 @@ def test_runs_pep8_againts_entire_src_dir(self): def test_outputs_test_notice_without_specific_path(self): self.renderer.notice.assert_any_call( - 'Executing Pep8 against all matching files.\n') + 'Executing Pep8 against source directory.\n') class FrostedHelperConfiguredToCheckAllFiles(HelperTests): From 99846c51c9d4d1ede841db1e57326527a5fc0fdd Mon Sep 17 00:00:00 2001 From: Thomas W Date: Mon, 6 Oct 2014 00:50:51 -0700 Subject: [PATCH 33/37] Overhaul documentation --- README.md | 174 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 142 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 0bd6ff5..aba5ee5 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,6 @@ Spare your alt and tab keys by automatically running your project's test suite whenever files change. -Testtube uses [watchdog](https://github.com/gorakhargosh/watchdog/) to monitor -a given path for file changes. It could fairly be described as a simpler -(read: easier to use) implementation of watchdog's included "watchmedo" -utility. - ## Installation Install testtube like you'd install any other python package: @@ -23,36 +18,137 @@ pip install testtube ## Usage -### Configure testtube +### 1. Configure testtube The simplest way to configure testtube is to drop a tube.py file in whatever -directory you'll be running `stir` from. The only thing that needs to be -in that file is a list of tuples named `PATTERNS` consisting of a regular -expression and a list of tests to run. +directory you'll be running the testtube watch command (`stir`) from. +The only thing that needs to be in that file is an iterable of tuples named +`PATTERNS` consisting of a regular expression and a list of tests to run. -Here's an example: +Here's an example `tube.py` file from the testtube repo: ```python -from testtube.helpers import pep8_all, pyflakes_all, nosetests_all +from testtube.helpers import Frosted, Nosetests, Pep257, Flake8 PATTERNS = ( - (r'.*\.py', [pep8_all, pyflakes_all, nosetests_all]), + # Run pep257 check against a file if it changes, excluding files that have + # test_ or tube.py in the name. + # If this test fails, don't make any noise (0 bells on failure) + ( + r'((?!test_)(?!tube\.py).)*\.py$', + [Pep257(bells=0)] + ), + # Run flake8 and Frosted on the entire project when a python file changes. + # If these checks fail, abort the entire test suite because failure might + # be due to a syntax error. There's no point running the subsequent tests + # if there is such an error. + ( + r'.*\.py$', + [Flake8(all_files=True), Frosted(all_files=True)], + {'fail_fast': True} + ), + # Run the test suite whenever python or test config files change. + ( + r'(.*setup\.cfg$)|(.*\.coveragerc)|(.*\.py$)', + [Nosetests()] + ) ) ``` -Given the configuration above, testtube will match the full path to the -changed file against `r'.*\.py'`. If it matches, it will then run the -following tests: `pep8_all`, `pyflakes_all`, `nosetests_all`. +In the example above, there are a series of patterns, coupled with a list of +callable tests generated via builtin helpers and, in one case, an optional test +group configuration. + +A test, at its simplest, is just a method that returns `True` or `False` after +being passed the path to a changed file and a regular expression +match object for the path's match against the test group's regular expression. +The example uses several helpers that ship with testtube. These helpers +are callable objects that can be configured in various ways when they are +instantiated. -Testtube comes with a number of helpers, which you can find in +Testtube comes with a number of these helpers, which you can find in [helpers.py](https://github.com/thomasw/testtube/blob/master/testtube/helpers.py). They are designed to save you from writing your own tests as much -as possible. If they don't meet your needs, see the "Writing your own tests" -section below. +as possible. If they don't meet your needs, see +[Writing your own tests](#writing-your-own-tests). + +Included helpers: + +* Pep8 +* Pyflakes +* Frosted +* Pep257 +* Nosetests +* PythonSetupPyTest (runs python setup.py when matching files change) + +Helpers typically accept the following arguments when instantiated: + +* `all_files`: run the test against the entire source directory instead of just + the changed file (which is the default behavior) +* `fail_fast`: Abort running the rest of the test group if the test fails. +* `bells`: On failure, testtube will audibly notify you 3 times unless otherwise + specified +* `name`: The name of the test in test report output + +The following generates a pep8 test configured to run against all files, +abort processing of its test group on failure, alert the user 5 times audibly, +and show up as "follow pep8 dude" in test report output: + +```python +from testtube.helpers import Pep8 + +helper = Pep8( + all_files=True, fail_fast=True, bells=5, name='follow pep8 dude') +``` + +Note that helpers, once instantiated, are just callables that return `True` or +`False`: + +```python +# Once configured, helpers are callables (they act like methods) that +# accept a path to a python file and a regex match object (though the +# match object isn't a requirement). + +helper('/path/to/some/file.py', None) +``` -### Stir it +And here's that same example fully incorporated into a tube.py file: - > stir +```python +from testtube.helpers import Pep8 + + +PATTERNS = [ + [ + # Pattern + r'.*\.py$', + # list of callable tests to run + [ + Pep8( + all_files=True, fail_fast=True, bells=5, + name='follow pep8 dude') + ] + ] +] +``` + +The behavior of helpers can be customized as necessary by overriding +specific methods. See [helpers.py](https://github.com/thomasw/testtube/blob/master/testtube/helpers.py) +for further information. + +In additional to configuring helpers, test groups can also be configured: + +* fail_fast: abort processing of subsequent test groups if all tests in the + configured group did not pass. + +In the first example tube.py file, the second test group is configured to abort +the rest of the test suite if either Flake8 or Frosted fail. + +### 2. Stir it + +Once you have a tube.py file, tell testtube to watch your project for changes: + + $ stir testtube is now watching /Path/to/CWD/ for changes... By default, stir will watch your current working directory and configure @@ -71,19 +167,19 @@ optional arguments: -h, --help show this help message and exit --src_dir SRC_DIR The directory to watch for changes. (Defaults to CWD) --settings SETTINGS Path to a testtube settings file that defines which - tests to run (Defaults to "tube.py" - your settings file - must be importable and the path must be relative to - your CWD) + tests to run (Defaults to "tube.py" - your settings + file must be importable and the path must be relative + to your CWD) ``` ### Writing your own tests If the included helpers don't do what you need, you can write your own tests -right in your settings module. Simply define a callable that accepts at least -one argument and add it to your patterns list: +right in your settings module. Simply define a callable that accepts two +arguments and add it to your patterns list: ```python -def mytest(changed_file): +def mytest(changed_file, match_obj): print "Oh snap, %s just changed" % changed_file PATTERNS = ( @@ -91,19 +187,33 @@ PATTERNS = ( ) ``` -Fortunately, tests can be a bit more clever than that. If you define it like -the following, testtube will pass it all of the named sub patterns in your -regular expression: +If you'd like to write tests that are configurable like the builtin helpers, +you can simply extend the base helper class. Here's a tube.py file that outputs +the file tree for the entire project each time a python file changes: ```python -def mysmartertest(changed_file, **kwargs): - print "%s in %s/ changed." % (changed_file, kwargs['dir']) +from testtube.helpers import Helper + + +class ProjectTree(Helper): + command = 'tree' + all_files = True + + def __init__(self, **kwargs): + super(ProjectTree, self).__init__() + + # TreeOutput only works on all files, so override any contrary config + self.all_files = True PATTERNS = ( - (r'.*/(?P[^/]*)/.*\.py$', [mysmartertest]), + (r'.*\.py$', [ProjectTree(all_files=True)]), ) + ``` +Note that this example requires tree to be installed on your system +(`$ brew install tree` for OS X users). + ## Everything else Copyright (c) [Thomas Welfley](http://welfley.me). See From cc0dd95531832e7641892daac8056d03545f9743 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Mon, 6 Oct 2014 00:58:11 -0700 Subject: [PATCH 34/37] Add caveats section to the readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index aba5ee5..6cd40cd 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,14 @@ PATTERNS = ( Note that this example requires tree to be installed on your system (`$ brew install tree` for OS X users). +## Caveats + +* Note the difference between `r'.*\.py'` and `r'.*\.py$'`. If you leave off + that `$`, then testtube will run your tests everytime pyc files change. +* testtube doesn't currently reload its own configuration when it changes. If + you reconfigure things, you'll need to kill testtube and restart it for those + changes to take effect. + ## Everything else Copyright (c) [Thomas Welfley](http://welfley.me). See From 96b13ca8ab8370cee1575a543a3bc1746c2b4502 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Mon, 6 Oct 2014 00:58:23 -0700 Subject: [PATCH 35/37] Fix readme typos --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6cd40cd..b7f14b5 100644 --- a/README.md +++ b/README.md @@ -136,13 +136,13 @@ The behavior of helpers can be customized as necessary by overriding specific methods. See [helpers.py](https://github.com/thomasw/testtube/blob/master/testtube/helpers.py) for further information. -In additional to configuring helpers, test groups can also be configured: +In addition to configuring helpers, test groups can also be configured: -* fail_fast: abort processing of subsequent test groups if all tests in the +* `fail_fast`: abort processing of subsequent test groups if all tests in the configured group did not pass. In the first example tube.py file, the second test group is configured to abort -the rest of the test suite if either Flake8 or Frosted fail. +the rest of the test suite if either `Flake8` or `Frosted` fail. ### 2. Stir it @@ -172,7 +172,7 @@ optional arguments: to your CWD) ``` -### Writing your own tests +## Writing your own tests If the included helpers don't do what you need, you can write your own tests right in your settings module. Simply define a callable that accepts two From d764151cba8c5dd6ae9047924dc6e3297b0f224f Mon Sep 17 00:00:00 2001 From: Thomas W Date: Mon, 6 Oct 2014 01:04:35 -0700 Subject: [PATCH 36/37] Bump version and update changelog --- CHANGELOG.md | 13 +++++++++++++ testtube/__init__.py | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ecf15..6f2560b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 1.0.0 + +* Make tests configurable +* Make test groups configurable +* Centralizes output in a renderer object +* Adds support for audible bells +* Adds test group fail fast support (aborts test run) +* Adds test fail fast support (aborts test group) +* Adds helper base class to make writing tests easier +* Adds a frosted helper +* Rewrite of configuration handling +* Eliminates redundant helpers: pep8_all, pyflakes_all, nosetests_all + ## 0.2.0 * Added python 3 support diff --git a/testtube/__init__.py b/testtube/__init__.py index 472c524..f2d11f4 100644 --- a/testtube/__init__.py +++ b/testtube/__init__.py @@ -1,2 +1,2 @@ __author__ = "Thomas Welfley" -__version__ = "0.2.0" +__version__ = "1.0.0" From c9d2a53c7ab8a0ac21be60b17d002859d6debf86 Mon Sep 17 00:00:00 2001 From: Thomas W Date: Mon, 6 Oct 2014 01:07:40 -0700 Subject: [PATCH 37/37] Add compatibility note. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b7f14b5..b7f4e12 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ Install testtube like you'd install any other python package: pip install testtube ``` +testtube is tested with Python 2.6, 2.7, and 3.4 and pypy. + ## Usage ### 1. Configure testtube