├── .gitignore ├── setup.cfg ├── setup.py ├── README.rst ├── LICENSE ├── syncasync.py └── test_syncasync.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .pytest_cache/ 3 | *.egg-info/ 4 | build/ 5 | dist/ 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | 4 | [flake8] 5 | max-line-length = 119 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import find_packages, setup 3 | 4 | from syncasync import __version__ 5 | 6 | 7 | # We use the README as the long_description 8 | readme_path = os.path.join(os.path.dirname(__file__), 'README.rst') 9 | 10 | 11 | setup( 12 | name='syncasync', 13 | version=__version__, 14 | url='https://github.com/w1z2g3/syncasync', 15 | author='Django Software Foundation', 16 | author_email='foundation@djangoproject.com', 17 | description='Sync-to-async and async-to-sync function wrappers', 18 | long_description=open(readme_path).read(), 19 | license='BSD', 20 | zip_safe=False, 21 | py_modules=['syncasync'], 22 | include_package_data=True, 23 | extras_require={ 24 | 'tests': [ 25 | 'pytest~=3.3', 26 | 'pytest-asyncio~=0.8', 27 | ], 28 | }, 29 | classifiers=[ 30 | 'Development Status :: 5 - Production/Stable', 31 | 'Environment :: Web Environment', 32 | 'Intended Audience :: Developers', 33 | 'License :: OSI Approved :: BSD License', 34 | 'Operating System :: OS Independent', 35 | 'Programming Language :: Python', 36 | 'Programming Language :: Python :: 3', 37 | 'Programming Language :: Python :: 3.5', 38 | 'Programming Language :: Python :: 3.6', 39 | 'Topic :: Internet :: WWW/HTTP', 40 | ], 41 | ) 42 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Sync-to-async and async-to-sync function wrappers 2 | ================================================= 3 | 4 | This package is based on https://github.com/django/asgiref/blob/master/asgiref/sync.py 5 | 6 | Sync-to-async and async-to-sync function wrappers 7 | ------------------------------------------------- 8 | 9 | These allow you to wrap or decorate async or sync functions to call them from 10 | the other style (so you can call async functions from a synchronous thread, 11 | or vice-versa). 12 | 13 | In particular: 14 | 15 | * AsyncToSync lets a synchronous subthread stop and wait while the async 16 | function is called on the main thread's event loop, and then control is 17 | returned to the thread when the async function is finished. 18 | 19 | * SyncToAsync lets async code call a synchronous function, which is run in 20 | a threadpool and control returned to the async coroutine when the synchronous 21 | function completes. 22 | 23 | The idea is to make it easier to call synchronous APIs from async code and 24 | asynchronous APIs from synchronous code so it's easier to transition code from 25 | one style to the other. In the case of Channels, we wrap the (synchronous) 26 | Django view system with SyncToAsync to allow it to run inside the (asynchronous) 27 | ASGI server. 28 | 29 | 30 | Dependencies 31 | ------------ 32 | 33 | ``syncasync`` requires Python 3.5 or higher. 34 | 35 | 36 | Test 37 | ---- 38 | 39 | To run tests, make sure you have installed the ``tests`` extra with the package:: 40 | 41 | pip install -e .[tests] 42 | pytest 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Django Software Foundation and individual contributors. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of Django nor the names of its contributors may be used 15 | to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /syncasync.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import concurrent.futures 3 | import functools 4 | import threading 5 | 6 | 7 | __version__ = "20180812" 8 | 9 | 10 | class AsyncToSync: 11 | """ 12 | Utility class which turns an awaitable that only works on the thread with 13 | the event loop into a synchronous callable that works in a subthread. 14 | 15 | Must be initialised from the main thread. 16 | """ 17 | 18 | def __init__(self, awaitable): 19 | self.awaitable = awaitable 20 | 21 | def __call__(self, *args, **kwargs): 22 | # Make a future for the return information 23 | call_result = concurrent.futures.Future() 24 | threadlocal = False 25 | try: 26 | main_event_loop = asyncio.get_event_loop() 27 | except RuntimeError: 28 | # There's no event loop in this thread. Look for the threadlocal if 29 | # we're inside SyncToAsync 30 | main_event_loop = getattr(SyncToAsync.threadlocal, "main_event_loop", None) 31 | threadlocal = True 32 | if main_event_loop and main_event_loop.is_running(): 33 | if threadlocal: 34 | # Schedule a synchronous callback to the thread local event loop. 35 | main_event_loop.call_soon_threadsafe( 36 | main_event_loop.create_task, 37 | self.main_wrap(args, kwargs, call_result) 38 | ) 39 | else: 40 | # Calling coroutine from main async thread will cause race. 41 | # Call the coroutine in a new thread. 42 | def run_in_thread(): 43 | loop = asyncio.new_event_loop() 44 | asyncio.set_event_loop(loop) 45 | try: 46 | loop.run_until_complete(self.main_wrap(args, kwargs, call_result)) 47 | finally: 48 | loop.close() 49 | thread = threading.Thread(target=run_in_thread) 50 | thread.start() 51 | thread.join() 52 | else: 53 | # Make our own event loop and run inside that. 54 | loop = asyncio.new_event_loop() 55 | asyncio.set_event_loop(loop) 56 | try: 57 | loop.run_until_complete(self.main_wrap(args, kwargs, call_result)) 58 | finally: 59 | try: 60 | if hasattr(loop, "shutdown_asyncgens"): 61 | loop.run_until_complete(loop.shutdown_asyncgens()) 62 | finally: 63 | loop.close() 64 | asyncio.set_event_loop(main_event_loop) 65 | # Wait for results from the future. 66 | return call_result.result() 67 | 68 | def __get__(self, parent, objtype): 69 | """ 70 | Include self for methods 71 | """ 72 | return functools.partial(self.__call__, parent) 73 | 74 | async def main_wrap(self, args, kwargs, call_result): 75 | """ 76 | Wraps the awaitable with something that puts the result into the 77 | result/exception future. 78 | """ 79 | try: 80 | result = await self.awaitable(*args, **kwargs) 81 | except Exception as e: 82 | call_result.set_exception(e) 83 | else: 84 | call_result.set_result(result) 85 | 86 | 87 | class SyncToAsync: 88 | """ 89 | Utility class which turns a synchronous callable into an awaitable that 90 | runs in a threadpool. It also sets a threadlocal inside the thread so 91 | calls to AsyncToSync can escape it. 92 | """ 93 | 94 | threadlocal = threading.local() 95 | 96 | def __init__(self, func): 97 | self.func = func 98 | 99 | async def __call__(self, *args, **kwargs): 100 | loop = asyncio.get_event_loop() 101 | future = loop.run_in_executor( 102 | None, 103 | functools.partial(self.thread_handler, loop, *args, **kwargs), 104 | ) 105 | return await asyncio.wait_for(future, timeout=None) 106 | 107 | def __get__(self, parent, objtype): 108 | """ 109 | Include self for methods 110 | """ 111 | return functools.partial(self.__call__, parent) 112 | 113 | def thread_handler(self, loop, *args, **kwargs): 114 | """ 115 | Wraps the sync application with exception handling. 116 | """ 117 | # Set the threadlocal for AsyncToSync 118 | self.threadlocal.main_event_loop = loop 119 | # Run the function 120 | return self.func(*args, **kwargs) 121 | 122 | 123 | # Lowercase is more sensible for most things 124 | sync_to_async = SyncToAsync 125 | async_to_sync = AsyncToSync 126 | -------------------------------------------------------------------------------- /test_syncasync.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import threading 3 | import time 4 | from concurrent.futures import ThreadPoolExecutor 5 | 6 | import pytest 7 | 8 | from syncasync import async_to_sync, sync_to_async 9 | 10 | 11 | @pytest.mark.asyncio 12 | async def test_sync_to_async(): 13 | """ 14 | Tests we can call sync functions from an async thread 15 | (even if the number of thread workers is less than the number of calls) 16 | """ 17 | # Define sync function 18 | def sync_function(): 19 | time.sleep(1) 20 | return 42 21 | # Wrap it 22 | async_function = sync_to_async(sync_function) 23 | # Check it works right 24 | start = time.monotonic() 25 | result = await async_function() 26 | end = time.monotonic() 27 | assert result == 42 28 | assert end - start >= 1 29 | # Set workers to 1, call it twice and make sure that works right 30 | loop = asyncio.get_event_loop() 31 | old_executor = loop._default_executor 32 | loop.set_default_executor(ThreadPoolExecutor(max_workers=1)) 33 | try: 34 | start = time.monotonic() 35 | await asyncio.wait([async_function(), async_function()]) 36 | end = time.monotonic() 37 | # It should take at least 2 seconds as there's only one worker. 38 | assert end - start >= 2 39 | finally: 40 | loop.set_default_executor(old_executor) 41 | 42 | 43 | @pytest.mark.asyncio 44 | async def test_sync_to_async_decorator(): 45 | """ 46 | Tests sync_to_async as a decorator 47 | """ 48 | # Define sync function 49 | @sync_to_async 50 | def test_function(): 51 | time.sleep(1) 52 | return 43 53 | # Check it works right 54 | result = await test_function() 55 | assert result == 43 56 | 57 | 58 | @pytest.mark.asyncio 59 | async def test_sync_to_async_method_decorator(): 60 | """ 61 | Tests sync_to_async as a method decorator 62 | """ 63 | # Define sync function 64 | class TestClass: 65 | @sync_to_async 66 | def test_method(self): 67 | time.sleep(1) 68 | return 44 69 | # Check it works right 70 | instance = TestClass() 71 | result = await instance.test_method() 72 | assert result == 44 73 | 74 | 75 | @pytest.mark.asyncio 76 | async def test_async_to_sync_to_async(): 77 | """ 78 | Tests we can call async functions from a sync thread created by async_to_sync 79 | (even if the number of thread workers is less than the number of calls) 80 | """ 81 | result = {} 82 | 83 | # Define async function 84 | async def inner_async_function(): 85 | result["worked"] = True 86 | return 65 87 | 88 | # Define sync function 89 | def sync_function(): 90 | return async_to_sync(inner_async_function)() 91 | 92 | # Wrap it 93 | async_function = sync_to_async(sync_function) 94 | # Check it works right 95 | number = await async_function() 96 | assert number == 65 97 | assert result["worked"] 98 | 99 | 100 | def test_async_to_sync(): 101 | """ 102 | Tests we can call async_to_sync outside of an outer event loop. 103 | """ 104 | result = {} 105 | 106 | # Define async function 107 | async def inner_async_function(): 108 | await asyncio.sleep(0) 109 | result["worked"] = True 110 | return 84 111 | 112 | # Run it 113 | sync_function = async_to_sync(inner_async_function) 114 | number = sync_function() 115 | assert number == 84 116 | assert result["worked"] 117 | 118 | 119 | def test_async_to_sync_decorator(): 120 | """ 121 | Tests we can call async_to_sync as a function decorator 122 | """ 123 | result = {} 124 | 125 | # Define async function 126 | @async_to_sync 127 | async def test_function(): 128 | await asyncio.sleep(0) 129 | result["worked"] = True 130 | return 85 131 | 132 | # Run it 133 | number = test_function() 134 | assert number == 85 135 | assert result["worked"] 136 | 137 | 138 | def test_async_to_sync_method_decorator(): 139 | """ 140 | Tests we can call async_to_sync as a function decorator 141 | """ 142 | result = {} 143 | 144 | # Define async function 145 | class TestClass: 146 | @async_to_sync 147 | async def test_function(self): 148 | await asyncio.sleep(0) 149 | result["worked"] = True 150 | return 86 151 | 152 | # Run it 153 | instance = TestClass() 154 | number = instance.test_function() 155 | assert number == 86 156 | assert result["worked"] 157 | 158 | 159 | @pytest.mark.asyncio 160 | async def test_async_to_sync_in_async(): 161 | """ 162 | Tests we can call async_to_sync from an async loop 163 | """ 164 | result = {} 165 | 166 | # Define async function 167 | async def inner_async_function(): 168 | await asyncio.sleep(0) 169 | result["worked"] = True 170 | return 84 171 | 172 | # Run it 173 | sync_function = async_to_sync(inner_async_function) 174 | number = sync_function() 175 | assert number == 84 176 | assert result["worked"] 177 | 178 | 179 | def test_async_to_sync_in_thread(): 180 | """ 181 | Tests we can call async_to_sync inside a thread 182 | """ 183 | result = {} 184 | 185 | # Define async function 186 | @async_to_sync 187 | async def test_function(): 188 | await asyncio.sleep(0) 189 | result["worked"] = True 190 | 191 | # Make a thread 192 | thread = threading.Thread(target=test_function) 193 | thread.start() 194 | thread.join() 195 | 196 | # Run it 197 | assert result["worked"] 198 | --------------------------------------------------------------------------------