Skip to content

Commit

Permalink
Merge 92e144d into 076d4d5
Browse files Browse the repository at this point in the history
  • Loading branch information
lvh committed Aug 8, 2014
2 parents 076d4d5 + 92e144d commit d0c49d0
Show file tree
Hide file tree
Showing 3 changed files with 165 additions and 20 deletions.
72 changes: 72 additions & 0 deletions README.rst
Expand Up @@ -144,6 +144,75 @@ It's probably better to write a small utility function that either
constructs a new ``Thimble`` that uses a shared thread pool, or always
returns the same thimble.

Attribute hooks
~~~~~~~~~~~~~~~

Sometimes, it isn't good enough to simply call a method in a thread
somewhere. You may need to modify the arguments before calling the
underlying method, too. For example, if the underlying method takes a
callback function, it may be called in some other thread, but if the
callback function expects to be able to interact with the reactor, it
has to be called in the reactor thread. For example:

>>> class FancyCar(Car):
... def __init__(self):
... self.alarm_callback = None
... def register_alarm_callback(self, callback):
... self.alarm_callback = callback
... def alarm(self):
... print " WOO ".join(["WEE"] * 5)
... if self.alarm_callback is not None:
... self.alarm_callback()
>>> def alarm_callback():
... # This uses Twisted to talk to the SMS service, so it
... # needs to be called in the reactor thread.
... print "sending sms... hopefully I am in the reactor thread!"

To do this, define an attribute hook function. That's a function that
takes the thimble instance, the attribute name being looked up, and
its current value; then returns its replacement value.

>>> from functools import partial, wraps
>>> from inspect import getcallargs
>>> def hook(thimble, attr, val):
... @wraps(val)
... def run_callback_in_reactor_thread_wrapper(*a, **kw):
... callargs = getcallargs(val, *a, **kw)
... orig_callback = callargs["callback"]
... def wrapped_callback():
... print "calling original callback in reactor thread"
... thimble._reactor.callFromThread(orig_callback)
... callargs["callback"] = wrapped_callback
... return val(callback=wrapped_callback)
... return run_callback_in_reactor_thread_wrapper

While all of this ``getcallargs`` trickery makes little sense when
you know that there's only one such callback and it doesn't take any
arguments, it's included here in the documentation because it's:

- particularly useful when you have a wide array of methods with
slightly different signatures, but which all have a callback method
that needs to be wrapped like the above one (a reasonably common
case),
- fairly unknown.

Set up the thimble:

>>> fancy_car = FancyCar()
>>> fancy_car_thimble = Thimble(reactor, pool, fancy_car,
... ["drive_to"], {"register_alarm_callback": hook})

Now, when we access that method, we get the wrapper:

>>> fancy_car_thimble.register_alarm_callback(alarm_callback)
>>> fancy_car_thimble.alarm()
WEE WOO WEE WOO WEE WOO WEE WOO WEE
calling original callback in reactor thread
sending sms... hopefully I am in the reactor thread!

You can also use hooks for methods that are asynchronified. The
attribute hook is evaluated *before* the method is made asynchronous.

Changelog
=========

Expand All @@ -154,6 +223,9 @@ Thimble uses SemVer_.
v0.2.0
------

- Added attribute hooks.
- Remove support for 2.6, because it doesn't have
``inspect.getcallargs``
- Minor updates to the tox CI set up
- Upgraded dependencies

Expand Down
98 changes: 79 additions & 19 deletions thimble/test/test_wrapper.py
Expand Up @@ -5,18 +5,18 @@

class ExampleSynchronousThing(object):

"""An example thing with some blocking APIs which will be wrapped and
some non-blocking (but still synchronous, i.e. non-Deferred returning)
APIs that won't be.
"""
An example thing with some blocking APIs which will be wrapped and
some non-blocking (but still synchronous, i.e. not returning
Deferreds) APIs that won't be.
"""

@property
def _wrapped(self):
"""This is here to verify that the ``_wrapped`` attribute of the.
"""
This is here to verify that the ``_wrapped`` attribute of the
:class:`Thimble` is used before the ``_wrapped`` attribute of
the wrapped object.
"""
return None

Expand All @@ -34,43 +34,103 @@ def blocking_method(self, first, second):
"""A blocking method that adds two numbers."""
return first + second

def hooked_method(self, number):
"""A non-blocking method that just returns the given number."""
return number

def hooked_blocking_method(self, number):
"""A blocking method that just returns the given number."""
return number


class _TestSetupMixin(object):

def setUp(self):
self.reactor = FakeReactor()
self.pool = FakeThreadPool()
self.wrapped = ExampleSynchronousThing()
self.thimble = Thimble(
self.reactor,
self.pool,
self.wrapped,
['blocking_method'])
self.thimble = Thimble(self.reactor,
self.pool,
self.wrapped,
['blocking_method',
'hooked_blocking_method'],
dict.fromkeys(["hooked_method",
"hooked_blocking_method"],
self.hook))
self.expected_attr = None

def hook(self, thimble, attr, val):
self.assertIdentical(thimble, self.thimble)
self.assertIdentical(attr, self.expected_attr)
return lambda x: val(x + 1)


class LegacySignatureTests(SynchronousTestCase):
def test_without_attr_hooks(self):
"""
Instantiating a thimble without the attr_hooks attribute works.
If no attr hooks are specified, the thimble gets a new, empty
dict as their attr hooks.
"""
thimble = Thimble(None, None, None, [])
self.assertEqual(thimble._attr_hooks, {})
thimble2 = Thimble(None, None, None, [])
self.assertEqual(thimble2._attr_hooks, {})
self.assertNotIdentical(thimble._attr_hooks, thimble2._attr_hooks)


class AttributeAccessTests(_TestSetupMixin, SynchronousTestCase):

def test_blocking_method(self):
"""A blocking method is wrapped so that it is executed in the thread
pool."""
"""
A blocking method is wrapped so that it is executed in the thread
pool.
"""
d = self.thimble.blocking_method(1, second=2)
self.assertEqual(self.successResultOf(d), 3)

def test_non_blocking_property(self):
"""A non-blocking property is accessed directly.
"""
"""A non-blocking property is accessed directly."""
self.assertEqual(self.thimble.non_blocking_property, 123)

def test_accessing_wrapped_attribute(self):
"""Accessing the attribute of the :class:`Thimble` which also happens
"""
Accessing the attribute of the :class:`Thimble` which also happens
to be the name of attribute of the wrapped object (such as
``wrapped``) returns the attribute of the :class:`Thimble`.
"""
self.assertIdentical(self.thimble._wrapped, self.wrapped)
self.assertIdentical(self.thimble._wrapped._wrapped, None)

def test_hooked_method(self):
"""
When accessing a (non-blocking) method/attribute with a registered
hook, the hook is invoked.
"""
attr = "hooked_method"

before_hook = getattr(self.thimble._wrapped, attr)
self.assertEqual(before_hook(1), 1)

self.expected_attr = attr
self.assertEqual(getattr(self.thimble, attr)(1), 2)

def test_hooked_blocking_method(self):
"""
When accessing a blocking method with a registered hook, the hook is
invoked.
"""
attr = "hooked_blocking_method"

before_hook = getattr(self.thimble._wrapped, attr)
self.assertEqual(before_hook(1), 1)

self.expected_attr = attr

d = getattr(self.thimble, attr)(1)
self.assertEqual(self.successResultOf(d), 2)


class ThreadPoolStartAndCleanupTests(_TestSetupMixin, SynchronousTestCase):

Expand Down
15 changes: 14 additions & 1 deletion thimble/wrapper.py
Expand Up @@ -7,7 +7,8 @@ class Thimble(object):

"""A Twisted thread-pool wrapper for a blocking API."""

def __init__(self, reactor, pool, wrapped, blocking_methods):
def __init__(self, reactor, pool, wrapped, blocking_methods,
attr_hooks=None):
"""Initialize a :class:`Thimble`.
:param reactor: The reactor that will handle events.
Expand All @@ -20,11 +21,19 @@ def __init__(self, reactor, pool, wrapped, blocking_methods):
:param blocking_methods: The names of the methods that will be wrapped
and executed in the thread pool.
:type blocking_methods: ``list`` of native ``str``
:param attr_hooks: A mapping of attribute names to attribute hook
functions. Attribute hook functions will be called with this
thimble object, the attribute name being accessed, and the
current attribute value; their return value will be used in
place of the real attribute value.
:type attr_hooks: :class:`dict` of native :class:`str` to ternary
callables
"""
self._reactor = reactor
self._pool = pool
self._wrapped = wrapped
self._blocking_methods = blocking_methods
self._attr_hooks = attr_hooks if attr_hooks is not None else {}

def _deferToThreadPool(self, f, *args, **kwargs):
"""Defer execution of ``f(*args, **kwargs)`` to the thread pool.
Expand All @@ -51,6 +60,10 @@ def __getattr__(self, attr):
"""
value = getattr(self._wrapped, attr)

hook = self._attr_hooks.get(attr)
if hook is not None:
value = hook(self, attr, value)

if attr in self._blocking_methods:
value = partial(self._deferToThreadPool, value)

Expand Down

0 comments on commit d0c49d0

Please sign in to comment.