diff --git a/.gitignore b/.gitignore index 89f6173..8858306 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,4 @@ target/ .env/ *.sublime-workspace -*~ \ No newline at end of file +*~ diff --git a/autocommand.sublime-project b/autocommand.sublime-project index 5ce799e..495fb50 100644 --- a/autocommand.sublime-project +++ b/autocommand.sublime-project @@ -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": [ { diff --git a/setup.py b/setup.py index 07d305a..2daaf62 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ def getfile(filename): setup( name='autocommand', - version='2.1.4', + version='2.1.5', packages=[ 'autocommand' ], diff --git a/src/autocommand/autoasync.py b/src/autocommand/autoasync.py index 2bcca5b..3c8ebdc 100644 --- a/src/autocommand/autoasync.py +++ b/src/autocommand/autoasync.py @@ -15,11 +15,42 @@ # You should have received a copy of the GNU Lesser General Public License # along with autocommand. If not, see . -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 @@ -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 @@ -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 diff --git a/test/test_autoasync.py b/test/test_autoasync.py index 80ef923..6ffb782 100644 --- a/test/test_autoasync.py +++ b/test/test_autoasync.py @@ -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 diff --git a/test/test_autocommand.py b/test/test_autocommand.py index 98e56e3..6531146 100644 --- a/test/test_autocommand.py +++ b/test/test_autocommand.py @@ -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+)")