Skip to content

Commit

Permalink
Merge pull request #3 from dave-shawley/add-setuptools-ext
Browse files Browse the repository at this point in the history
Add helper_run setuptools extension.
  • Loading branch information
gmr committed Mar 7, 2014
2 parents 5129dcb + aac4c0f commit 703e47d
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 1 deletion.
1 change: 1 addition & 0 deletions docs/api.rst
Expand Up @@ -7,3 +7,4 @@ helper API
controller
logging
parser
setupext
12 changes: 12 additions & 0 deletions docs/setupext.rst
@@ -0,0 +1,12 @@
Setup Tools Integration
=======================
Helper installs an additional distutils command named *run_helper* that will run a :class:`Controller` directly from your *setup.py*. This is a nice alternative to writing your our shell wrapper for use during development. If *setup.py* is executable, then you can run ``myapp.Controller`` with::

./setup.py run_helper -c etc/myapp.yml -C myapp.Controller

This functionality is a standard *distutils* entry point so it follows all of the same rules as other extensions such as *build_sphinx* or *nosetests*. The command line arguments can be included in the ``[run_helper]`` section of *setup.cfg*::

[run_helper]
configuration = etc/myapp.yml
controller = myapp.Controller

65 changes: 65 additions & 0 deletions helper/setupext.py
@@ -0,0 +1,65 @@
"""Add a setuptools command that runs a helper-based application."""
try:
from setuptools import Command
except ImportError:
from distutils.core import Command

from . import parser
from . import platform


class RunCommand(Command):

"""Run a helper-based application.
This extension is installed as a ``distutils.commands``
entry point that provides the *run_helper* command. When
run, it imports a :class:`helper.Controller` subclass by
name, creates a new instance, and runs it in the foreground
until interrupted. The dotted-name of the controller class
and an optional configuration file are provided as command
line parameters.
:param str configuration: the name of a configuration file
to pass to the application *(optional)*
:param str controller: the dotted-name of the Python class
to load and run
"""

description = 'run a helper.Controller'
user_options = [
('configuration=', 'c', 'path to application configuration file'),
('controller=', 'C', 'controller to run'),
]

def initialize_options(self):
"""Initialize parameters."""
self.configuration = None
self.controller = None

def finalize_options(self):
"""Required override that does nothing."""
pass

def run(self):
"""Import the controller and run it.
This mimics the processing done by :func:`helper.start`
when a controller is run in the foreground. A new instance
of ``self.controller`` is created and run until a keyboard
interrupt occurs or the controller stops on its own accord.
"""
segments = self.controller.split('.')
controller_class = reduce(getattr, segments[1:],
__import__('.'.join(segments[:-1])))
cmd_line = ['-f']
if self.configuration is not None:
cmd_line.extend(['-c', self.configuration])
args = parser.get().parse_args(cmd_line)
controller_instance = controller_class(args, platform)
try:
controller_instance.start()
except KeyboardInterrupt:
controller_instance.stop()
5 changes: 4 additions & 1 deletion setup.py
Expand Up @@ -52,4 +52,7 @@
package_data={'': ['LICENSE', 'README.rst']},
install_requires=requirements,
tests_require=tests_require,
zip_safe=True)
zip_safe=True,
entry_points={
'distutils.commands': ['run_helper = helper.setupext:RunCommand'],
})
131 changes: 131 additions & 0 deletions tests/setupext_tests.py
@@ -0,0 +1,131 @@
try:
import unittest2 as unittest
except ImportError:
import unittest

import mock

import helper.setupext


class PatchedTestMixin(object):

def setUp(self):
super(PatchedTestMixin, self).setUp()
self._patches = []

def tearDown(self):
for patcher in self._patches:
patcher.stop()
self._patches = []
super(PatchedTestMixin, self).tearDown()

def add_patch(self, name, **kwargs):
self._patches.append(mock.patch('helper.setupext.' + name, **kwargs))
return self._patches[-1].start()


class RunCommandTests(PatchedTestMixin, unittest.TestCase):

def assertHasAttribute(self, obj, attribute):
if getattr(obj, attribute, mock.sentinel.default) \
is mock.sentinel.default:
self.fail('Expected {0} to have attribute named {1}'.format(
obj, attribute))

def setUp(self):
super(RunCommandTests, self).setUp()
self.add_patch('Command.__init__', return_value=None)
self.command = helper.setupext.RunCommand(mock.Mock())

def test_description(self):
self.assertHasAttribute(helper.setupext.RunCommand, 'description')

def test_user_options(self):
self.assertEquals(helper.setupext.RunCommand.user_options[0],
('configuration=', 'c', mock.ANY))
self.assertEquals(helper.setupext.RunCommand.user_options[1],
('controller=', 'C', mock.ANY))

def test_initialize_options(self):
self.command.initialize_options()
self.assertIsNone(getattr(self.command, 'controller'))
self.assertIsNone(getattr(self.command, 'configuration'))

def test_finalize_options(self):
finalize_options = self.add_patch('Command.finalize_options')

self.command.finalize_options()
self.assertFalse(finalize_options.called)



class RunCommandRunTests(PatchedTestMixin, unittest.TestCase):

def setUp(self):
super(RunCommandRunTests, self).setUp()
self.controller_module = mock.Mock()
self.command_class = mock.Mock()
# `start` runs until Ctrl+C is pressed, mimic that
self.command_class.return_value.start.side_effect = KeyboardInterrupt

self.add_patch('Command.__init__', return_value=None)
self.import_stmt = self.add_patch('__import__', create=True)
self.getattr_stmt = self.add_patch('getattr', create=True)
self.getattr_stmt.side_effect = [self.controller_module,
self.command_class]
self.platform = self.add_patch('platform')
self.parser = self.add_patch('parser')

self.command = helper.setupext.RunCommand(mock.Mock())
self.command.controller = 'package.controller.command'
self.command.configuration = None

self.command.run()

def test_controller_is_imported(self):
self.import_stmt.assert_called_once_with('package.controller')
self.getattr_stmt.assert_any_call(self.import_stmt.return_value,
'controller')
self.getattr_stmt.assert_any_call(self.controller_module, 'command')

def test_parser_is_fetched_from_config(self):
self.parser.get.assert_called_once_with()

def test_command_line_is_parsed(self):
parser = self.parser.get.return_value
parser.parse_args.assert_called_once_with(['-f'])

def test_controller_is_created(self):
self.command_class.assert_called_once_with(
self.parser.get.return_value.parse_args.return_value,
self.platform,
)

def test_should_start_controller(self):
self.command_class.return_value.start.assert_called_once_with()

def test_should_stop_controller(self):
self.command_class.return_value.stop.assert_called_once_with()


class RunCommandParameterTests(PatchedTestMixin, unittest.TestCase):

def setUp(self):
super(RunCommandParameterTests, self).setUp()
self.add_patch('Command.__init__', return_value=None)
self.add_patch('__import__', create=True)
self.add_patch('getattr', create=True)

self.parser = self.add_patch('parser')
self.platform = self.add_patch('platform')

self.command = helper.setupext.RunCommand(mock.Mock())
self.command.controller = 'some.string'
self.command.configuration = mock.sentinel.config_file_path
self.command.run()

def test_configuration_parameter_passed_to_parser(self):
parser = self.parser.get.return_value
parser.parse_args.assert_called_once_with(
['-f', '-c', mock.sentinel.config_file_path])

0 comments on commit 703e47d

Please sign in to comment.