Skip to content

Commit

Permalink
Merge branch 'async-loop-fix'
Browse files Browse the repository at this point in the history
  • Loading branch information
Lucretiel committed Apr 21, 2017
2 parents 0f9bde4 + a8e9680 commit 8388756
Show file tree
Hide file tree
Showing 6 changed files with 80 additions and 13 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,4 @@ target/

.env/
*.sublime-workspace
*~
*~
9 changes: 9 additions & 0 deletions autocommand.sublime-project
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
{
"build_systems":
[
{
"file_regex": "^[ ]*File \"(...*?)\", line ([0-9]*)",
"name": "Anaconda Python Builder",
"selector": "source.python",
"shell_cmd": "\"/usr/local/bin/python3\" -u \"$file\""
}
],
"folders":
[
{
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ def getfile(filename):

setup(
name='autocommand',
version='2.1.4',
version='2.1.5',
packages=[
'autocommand'
],
Expand Down
56 changes: 45 additions & 11 deletions src/autocommand/autoasync.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,42 @@
# You should have received a copy of the GNU Lesser General Public License
# along with autocommand. If not, see <http://www.gnu.org/licenses/>.

from asyncio import get_event_loop
from asyncio import get_event_loop, iscoroutine
from functools import wraps
from inspect import signature


def _launch_forever_coro(coro, args, kwargs, loop):
'''
This helper function launches an async main function that was tagged with
forever=True. There are two possibilities:
- The function is a normal function, which handles initializing the event
loop, which is then run forever
- The function is a coroutine, which needs to be scheduled in the event
loop, which is then run forever
- There is also the possibility that the function is a normal function
wrapping a coroutine function
The function is therefore called unconditionally and scheduled in the event
loop if the return value is a coroutine object.
The reason this is a separate function is to make absolutely sure that all
the objects created are garbage collected after all is said and done; we
do this to ensure that any exceptions raised in the tasks are collected
ASAP.
'''

# Personal note: I consider this an antipattern, as it relies on the use of
# unowned resources. The setup function dumps some stuff into the event
# loop where it just whirls in the ether without a well defined owner or
# lifetime. For this reason, there's a good chance I'll remove the
# forever=True feature from autoasync at some point in the future.
thing = coro(*args, **kwargs)
if iscoroutine(thing):
loop.create_task(thing)


def autoasync(coro=None, *, loop=None, forever=False, pass_loop=False):
'''
Convert an asyncio coroutine into a function which, when called, is
Expand Down Expand Up @@ -70,6 +101,16 @@ def server(host, port, loop):
forever=forever,
pass_loop=pass_loop)

# The old and new signatures are required to correctly bind the loop
# parameter in 100% of cases, even if it's a positional parameter.
# NOTE: A future release will probably require the loop parameter to be
# a kwonly parameter.
if pass_loop:
old_sig = signature(coro)
new_sig = old_sig.replace(parameters=(
param for name, param in old_sig.parameters.items()
if name != "loop"))

@wraps(coro)
def autoasync_wrapper(*args, **kwargs):
# Defer the call to get_event_loop so that, if a custom policy is
Expand All @@ -86,21 +127,14 @@ def autoasync_wrapper(*args, **kwargs):
args, kwargs = bound_args.args, bound_args.kwargs

if forever:
# Explicitly don't create a reference to the created task. This
# ensures that if an exception is raised, it is shown as soon as
# possible, when the created task is garbage collected.
local_loop.create_task(coro(*args, **kwargs))
_launch_forever_coro(coro, args, kwargs, local_loop)
local_loop.run_forever()
else:
return local_loop.run_until_complete(coro(*args, **kwargs))

# Attach an updated signature, with the "loop" parameter filted out. This
# allows 'pass_loop' to be used with autoparse
# Attach the updated signature. This allows 'pass_loop' to be used with
# autoparse
if pass_loop:
old_sig = signature(coro)
new_sig = old_sig.replace(parameters=(
param for name, param in old_sig.parameters.items()
if name != "loop"))
autoasync_wrapper.__signature__ = new_sig

return autoasync_wrapper
23 changes: 23 additions & 0 deletions test/test_autoasync.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,29 @@ def async_main():
assert retrieved_value


def test_run_forever_func(context_loop):
@asyncio.coroutine
def stop_loop_after(t):
yield from asyncio.sleep(t)
context_loop.stop()

retrieved_value = False

@asyncio.coroutine
def set_value_after(t):
nonlocal retrieved_value
yield from asyncio.sleep(t)
retrieved_value = True

@autoasync(forever=True)
def main_func():
asyncio.async(set_value_after(0.1))
asyncio.async(stop_loop_after(0.2))

main_func()
assert retrieved_value


def test_defered_loop(context_loop, new_loop):
'''
Test that, if a new event loop is installed with set_event_loop AFTER the
Expand Down
1 change: 1 addition & 0 deletions test/test_autocommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def _asyncio_unavailable():
else:
return False


skip_if_async_unavailable = pytest.mark.skipif(
_asyncio_unavailable(),
reason="async tests require asyncio (python3.4+)")
Expand Down

0 comments on commit 8388756

Please sign in to comment.