├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── agent.py ├── setup.cfg ├── setup.py └── tests └── test_agent.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *,cover 45 | 46 | # Translations 47 | *.mo 48 | *.pot 49 | 50 | # Django stuff: 51 | *.log 52 | 53 | # Sphinx documentation 54 | docs/_build/ 55 | 56 | # PyBuilder 57 | target/ 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Chris Seto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.md 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Agent: Async generators for humans 2 | 3 | **agent** provides a simple decorator to create python 3.5 [asynchronous iterators](https://docs.python.org/3/reference/compound_stmts.html#async-for) via `yield`s 4 | 5 | ## Examples 6 | 7 | Make people wait for things for no reason! 8 | ```python 9 | import agent 10 | import asyncio 11 | 12 | @agent.gen # Shorthand decorator 13 | def wait_for_me(): 14 | yield 'Like ' 15 | yield from asyncio.sleep(1) 16 | yield 'the line ' 17 | yield from asyncio.sleep(10) 18 | yield 'at ' 19 | yield from asyncio.sleep(100) 20 | yield 'the DMV' 21 | 22 | async for part in wait_for_me(): 23 | print(part) 24 | ``` 25 | 26 | Paginate websites in an easy asynchronous manner. 27 | ```python 28 | import agent 29 | import aiohttp 30 | 31 | @agent.async_generator 32 | def gen(): 33 | page, url = 0, 'http://example.com/paginated/endpoint' 34 | while True: 35 | resp = yield from aiohttp.request('GET', url, params={'page': page}) 36 | resp_json = (yield from resp.json())['data'] 37 | if not resp_json: 38 | break 39 | for blob in resp_json['data']: 40 | yield blob 41 | page += 1 42 | 43 | # Later on.... 44 | 45 | async for blob in gen(): 46 | # Do work 47 | ``` 48 | 49 | 50 | **The possibilities are endless!** 51 | 52 | For additional, crazier, examples take a look in the [tests directory](tests/). 53 | 54 | 55 | ## Get it 56 | 57 | ```bash 58 | $ pip install -U agent 59 | ``` 60 | 61 | ## Caveats 62 | 63 | `yield from` syntax must be used as `yield` in an `async def` block is a syntax error. 64 | 65 | ```python 66 | async def generator(): 67 | yield 1 # Syntax Error :( 68 | ``` 69 | 70 | `asyncio.Future`s can not be yielded directly, they must be wrapped by `agent.Result`. 71 | 72 | 73 | ## License 74 | 75 | MIT licensed. See the bundled [LICENSE](LICENSE) file for more details. 76 | -------------------------------------------------------------------------------- /agent.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | 4 | __version__ = '0.1.2' 5 | __all__ = ( 6 | 'gen', 7 | 'anext', 8 | 'Result', 9 | 'AsyncGenerator', 10 | ) 11 | 12 | 13 | async def anext(gen): 14 | """The equivilent of next for async iterators 15 | 16 | Usage: 17 | >>> result = await anext(aiterable) 18 | """ 19 | return await gen.__anext__() 20 | 21 | 22 | def async_generator(func): 23 | """A decorator that turns the given function into an AsyncGenerator""" 24 | @functools.wraps(func) 25 | def wrapper(*args, **kwargs): 26 | return AsyncGenerator(func(*args, **kwargs)) 27 | return wrapper 28 | 29 | 30 | class Result: 31 | """Wraps futures, or other values, so them may be properly yielded""" 32 | def __init__(self, inner): 33 | self.inner = inner 34 | 35 | 36 | class AsyncGenerator: 37 | 38 | def __init__(self, gen): 39 | self.gen = gen 40 | 41 | async def __aiter__(self): 42 | """Makes this class an async *iteratable* 43 | 44 | Needs to return an async *iterator* which AsyncGenerator is 45 | See https://docs.python.org/3/glossary.html#term-asynchronous-iterable 46 | 47 | :returns: self 48 | """ 49 | return self 50 | 51 | async def __anext__(self): 52 | """Makes this class an async *iterator* 53 | 54 | Spins the interal coroutine then either: 55 | Awaits futures returned and recalls itself 56 | Unwraps Result classes 57 | Otherwise returns the yielded value 58 | """ 59 | try: 60 | item = next(self.gen) 61 | except StopIteration: 62 | raise StopAsyncIteration 63 | 64 | if isinstance(item, Result): 65 | return item.inner 66 | 67 | # Note: anything that is "yield from"ed or awaited 68 | # techincally just yields a Future 69 | # Impossible to actually tell if this was a yielded future or 70 | # A "yield from"ed future, hence Result 71 | if isinstance(item, asyncio.Future): 72 | await item 73 | return await self.__anext__() 74 | 75 | return item 76 | 77 | # Nice aliases 78 | gen = async_generator 79 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from setuptools import setup 4 | 5 | 6 | def read(fname): 7 | with open(fname) as fp: 8 | content = fp.read() 9 | return content 10 | 11 | 12 | setup( 13 | name='agent', 14 | version=__import__('agent').__version__, 15 | description='Async generators for humans', 16 | long_description=read('README.md'), 17 | license=read('LICENSE'), 18 | author='Chris Seto', 19 | author_email='chriskseto@gmail.com', 20 | url='https://github.com/chrisseto/Agent', 21 | py_modules=['agent'], 22 | classifiers=[ 23 | 'Intended Audience :: Developers', 24 | 'License :: OSI Approved :: MIT License', 25 | 'Programming Language :: Python :: 3.5', 26 | 'Programming Language :: Python :: Implementation :: CPython', 27 | ], 28 | test_suite='tests', 29 | ) 30 | -------------------------------------------------------------------------------- /tests/test_agent.py: -------------------------------------------------------------------------------- 1 | import agent 2 | import pytest 3 | import asyncio 4 | 5 | 6 | @pytest.mark.asyncio 7 | async def test_generator(): 8 | 9 | @agent.async_generator 10 | def inner_gen(): 11 | yield 0 12 | yield 1 13 | yield from asyncio.sleep(.01) 14 | yield 2 15 | yield from asyncio.sleep(.01) 16 | yield 3 17 | 18 | gen = inner_gen() 19 | assert isinstance(gen, agent.AsyncGenerator) 20 | assert hasattr(gen, '__anext__') 21 | assert hasattr(gen, '__aiter__') 22 | 23 | returns = iter(range(4)) 24 | async for i in inner_gen(): 25 | assert i == next(returns) 26 | 27 | 28 | @pytest.mark.asyncio 29 | async def test_anext(): 30 | fut = asyncio.coroutine(lambda: (0, 1, 2, 3, 4, 5, 6))() 31 | 32 | @agent.async_generator 33 | def inner_gen(): 34 | for x in (yield from fut): 35 | yield x 36 | 37 | gen = inner_gen() 38 | for i in range(6): 39 | assert i == await agent.anext(gen) 40 | 41 | 42 | @pytest.mark.asyncio 43 | async def test_coroutines(): 44 | 45 | @agent.async_generator 46 | def inner_gen(): 47 | yield from asyncio.coroutine(lambda: 'Not sent')() 48 | yield (yield from asyncio.coroutine(lambda: 'yield_from')()) 49 | yield asyncio.coroutine(lambda: 'yield')() 50 | 51 | gen = inner_gen() 52 | assert await agent.anext(gen) == 'yield_from' 53 | assert await (await agent.anext(gen)) == 'yield' 54 | 55 | 56 | @pytest.mark.asyncio 57 | async def test_futures(): 58 | 59 | @agent.async_generator 60 | def inner_gen(): 61 | fut = asyncio.Future() 62 | fut.set_result(10) 63 | yield agent.Result(fut) 64 | 65 | gen = inner_gen() 66 | assert await (await agent.anext(gen)) == 10 67 | --------------------------------------------------------------------------------