├── .github └── workflows │ ├── release.yaml │ └── tests.yaml ├── .gitignore ├── CHANGES ├── LICENSE ├── README.md ├── asyncio_tools.py ├── py.typed ├── pyproject.toml ├── requirements ├── dev-requirements.txt └── test-requirements.txt ├── scripts ├── lint.sh ├── release.sh └── run-tests.sh ├── setup.py └── tests ├── test-requirements.txt └── test_gathered_results.py /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | 8 | jobs: 9 | release: 10 | name: "Publish release" 11 | runs-on: "ubuntu-latest" 12 | 13 | steps: 14 | - uses: "actions/checkout@v3" 15 | - uses: "actions/setup-python@v4" 16 | with: 17 | python-version: "3.10" 18 | - name: "Install dependencies" 19 | run: "pip install -r requirements/dev-requirements.txt" 20 | - name: "Publish to PyPI" 21 | run: "./scripts/release.sh" 22 | env: 23 | TWINE_USERNAME: __token__ 24 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 25 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Test Suite 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | jobs: 10 | linters: 11 | runs-on: ubuntu-latest 12 | timeout-minutes: 5 13 | strategy: 14 | matrix: 15 | python-version: ["3.8", "3.9", "3.10"] 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Set up Python ${{ matrix.python-version }} 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | - name: Install dependencies 24 | run: | 25 | python -m pip install --upgrade pip 26 | pip install -r requirements/dev-requirements.txt 27 | pip install -r requirements/test-requirements.txt 28 | - name: Lint 29 | run: ./scripts/lint.sh 30 | 31 | unittests: 32 | runs-on: ubuntu-latest 33 | timeout-minutes: 30 34 | strategy: 35 | matrix: 36 | python-version: ["3.8", "3.9", "3.10"] 37 | 38 | steps: 39 | - uses: actions/checkout@v3 40 | - name: Set up Python ${{ matrix.python-version }} 41 | uses: actions/setup-python@v4 42 | with: 43 | python-version: ${{ matrix.python-version }} 44 | - name: Install dependencies 45 | run: | 46 | python -m pip install --upgrade pip 47 | pip install -r requirements/test-requirements.txt 48 | - name: Run unittests 49 | run: ./scripts/run-tests.sh 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | .vscode/settings.json 4 | dist/ 5 | build/ 6 | *.egg-info/ 7 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | 1.0.0 2 | ===== 3 | 4 | Fix bugs, and improve tests. Thanks to @jikuja. 5 | 6 | 0.1.1 7 | ===== 8 | 9 | Initial release. 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 piccolo-orm 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # asyncio_tools 2 | 3 | Useful utilities for working with asyncio. 4 | 5 | ## Installation 6 | 7 | ``` 8 | pip install asyncio-tools 9 | ``` 10 | 11 | ## gather 12 | 13 | Provides a convenient wrapper around `asyncio.gather`. 14 | 15 | ```python 16 | from asyncio_tools import gather, CompoundException 17 | 18 | 19 | async def good(): 20 | return 'OK' 21 | 22 | 23 | async def bad(): 24 | raise ValueError() 25 | 26 | 27 | async def main(): 28 | response = await gather( 29 | good(), 30 | bad(), 31 | good() 32 | ) 33 | 34 | # Check if a particular exception was raised. 35 | ValueError in response.exception_types 36 | # >>> True 37 | 38 | # To get all exceptions: 39 | print(response.exceptions) 40 | # >>> [ValueError()] 41 | 42 | # To get all instances of a particular exception: 43 | response.exceptions_of_type(ValueError) 44 | # >>> [ValueError()] 45 | 46 | # To get the number of exceptions: 47 | print(response.exception_count) 48 | # >>> 1 49 | 50 | # You can still access all of the results: 51 | print(response.all) 52 | # >>> ['OK', ValueError(), 'OK'] 53 | 54 | # And can access all successes (i.e. non-exceptions): 55 | print(response.successes) 56 | # >>> ['OK', 'OK'] 57 | 58 | # To get the number of successes: 59 | print(response.success_count) 60 | # >>> 2 61 | 62 | try: 63 | # To combines all of the exceptions into a single one, which merges the 64 | # messages. 65 | raise response.compound_exception() 66 | except CompoundException as compound_exception: 67 | print("Caught it") 68 | 69 | if ValueError in compound_exception.exception_types: 70 | print("Caught a ValueError") 71 | 72 | ``` 73 | 74 | Read some background on why `gather` is useful: 75 | 76 | - https://www.piccolo-orm.com/blog/exception-handling-in-asyncio/ 77 | - https://www.piccolo-orm.com/blog/asyncio-gather/ 78 | -------------------------------------------------------------------------------- /asyncio_tools.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import asyncio 3 | from functools import cached_property 4 | import typing as t 5 | 6 | 7 | __VERSION__ = "1.0.0" 8 | 9 | 10 | class CompoundException(Exception): 11 | """ 12 | Is used to aggregate several exceptions into a single exception, with a 13 | combined message. It contains a reference to the constituent exceptions. 14 | """ 15 | 16 | def __init__(self, exceptions: t.List[Exception]): 17 | self.exceptions = exceptions 18 | 19 | def __str__(self): 20 | return ( 21 | f"CompoundException, {len(self.exceptions)} errors [" 22 | + "; ".join( 23 | [ 24 | f"{i.__class__.__name__}: {i.__str__()}" 25 | for i in self.exceptions 26 | ] 27 | ) 28 | + "]" 29 | ) 30 | 31 | @cached_property 32 | def exception_types(self) -> t.List[t.Type[Exception]]: 33 | """ 34 | Returns the constituent exception types. 35 | 36 | Useful for checks like this: 37 | 38 | if TransactionError in compound_exception.exception_types: 39 | some_transaction_cleanup() 40 | 41 | """ 42 | return [i.__class__ for i in self.exceptions] 43 | 44 | 45 | class GatheredResults: 46 | 47 | # __dict__ is required for cached_property 48 | __slots__ = ("__results", "__dict__") 49 | 50 | def __init__(self, results: t.List[t.Any]): 51 | self.__results = results 52 | 53 | ########################################################################### 54 | 55 | @property 56 | def results(self): 57 | return self.__results 58 | 59 | @property 60 | def all(self) -> t.List[t.Any]: 61 | """ 62 | Just a proxy. 63 | """ 64 | return self.__results 65 | 66 | ########################################################################### 67 | 68 | @cached_property 69 | def exceptions(self) -> t.List[t.Type[Exception]]: 70 | """ 71 | Returns all exception instances which were returned by asyncio.gather. 72 | """ 73 | return [i for i in self.results if isinstance(i, Exception)] 74 | 75 | def exceptions_of_type( 76 | self, exception_type: t.Type[Exception] 77 | ) -> t.List[t.Type[Exception]]: 78 | """ 79 | Returns any exceptions of the given type. 80 | """ 81 | return [i for i in self.exceptions if isinstance(i, exception_type)] 82 | 83 | @cached_property 84 | def exception_types(self) -> t.List[t.Type[Exception]]: 85 | """ 86 | Returns the exception types which appeared in the response. 87 | """ 88 | return [i.__class__ for i in self.exceptions] 89 | 90 | @cached_property 91 | def exception_count(self) -> int: 92 | return len(self.exceptions) 93 | 94 | ########################################################################### 95 | 96 | @cached_property 97 | def successes(self) -> t.List[t.Any]: 98 | """ 99 | Returns all values in the response which aren't exceptions. 100 | """ 101 | return [i for i in self.results if not isinstance(i, Exception)] 102 | 103 | @cached_property 104 | def success_count(self) -> int: 105 | return len(self.successes) 106 | 107 | ########################################################################### 108 | 109 | def compound_exception(self) -> t.Optional[CompoundException]: 110 | """ 111 | Create a single exception which combines all of the exceptions. 112 | 113 | A function instead of a property to leave room for some extra args 114 | in the future. 115 | 116 | raise gathered_response.compound_exception() 117 | """ 118 | if not self.exceptions: 119 | return False 120 | 121 | return CompoundException(self.exceptions) 122 | 123 | 124 | async def gather(*coroutines: t.Sequence[t.Coroutine]) -> GatheredResults: 125 | """ 126 | A wrapper on top of asyncio.gather which makes handling the results 127 | easier. 128 | """ 129 | results = await asyncio.gather(*coroutines, return_exceptions=True) 130 | return GatheredResults(results) 131 | -------------------------------------------------------------------------------- /py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/piccolo-orm/asyncio_tools/8cbf409903eb79d12cd87270c48538ec1cef4bac/py.typed -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 79 3 | target-version = ['py37', 'py38', 'py39', 'py310'] 4 | -------------------------------------------------------------------------------- /requirements/dev-requirements.txt: -------------------------------------------------------------------------------- 1 | black 2 | flake8 3 | twine==4.0.1 4 | wheel==0.38.1 5 | -------------------------------------------------------------------------------- /requirements/test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Running black..." 4 | black --check asyncio_tools.py 5 | 6 | echo "Running flake8..." 7 | flake8 asyncio_tools.py 8 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | rm -rf ./dist/* 3 | python setup.py sdist bdist_wheel 4 | twine upload dist/* 5 | -------------------------------------------------------------------------------- /scripts/run-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | python -m pytest -s 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | from setuptools import setup 6 | 7 | from asyncio_tools import __VERSION__ as VERSION 8 | 9 | 10 | directory = os.path.abspath(os.path.dirname(__file__)) 11 | 12 | 13 | with open(os.path.join(directory, "README.md")) as f: 14 | LONG_DESCRIPTION = f.read() 15 | 16 | 17 | setup( 18 | name="asyncio_tools", 19 | version=VERSION, 20 | description="Useful utilities for working with asyncio.", 21 | long_description=LONG_DESCRIPTION, 22 | long_description_content_type="text/markdown", 23 | author="Daniel Townsend", 24 | author_email="dan@dantownsend.co.uk", 25 | python_requires=">=3.8.0", 26 | url="https://github.com/piccolo-orm/asyncio_tools", 27 | py_modules=["asyncio_tools"], 28 | data_files=[("", ["py.typed"])], 29 | install_requires=[], 30 | license="MIT", 31 | classifiers=[ 32 | "License :: OSI Approved :: MIT License", 33 | "Programming Language :: Python", 34 | "Programming Language :: Python :: 3", 35 | "Programming Language :: Python :: 3.8", 36 | "Programming Language :: Python :: 3.9", 37 | "Programming Language :: Python :: 3.10", 38 | "Programming Language :: Python :: Implementation :: CPython", 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /tests/test-requirements.txt: -------------------------------------------------------------------------------- 1 | pytest==5.3.5 2 | 3 | -------------------------------------------------------------------------------- /tests/test_gathered_results.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from unittest import TestCase 3 | 4 | from asyncio_tools import gather, CompoundException, GatheredResults 5 | 6 | 7 | async def good(): 8 | return "OK" 9 | 10 | 11 | async def bad(): 12 | raise ValueError("Bad value") 13 | 14 | 15 | class TestGatheredResults(TestCase): 16 | def test_exceptions(self): 17 | response: GatheredResults = asyncio.run(gather(good(), bad(), good())) 18 | self.assertTrue(ValueError in response.exception_types) 19 | self.assertTrue(response.exception_count == 1) 20 | 21 | def test_successes(self): 22 | response: GatheredResults = asyncio.run(gather(good(), bad(), good())) 23 | self.assertTrue(response.successes == ["OK", "OK"]) 24 | self.assertTrue(response.success_count == 2) 25 | 26 | def test_compound_exception(self): 27 | response: GatheredResults = asyncio.run( 28 | gather(good(), bad(), good(), bad()) 29 | ) 30 | 31 | with self.assertRaises(CompoundException): 32 | raise response.compound_exception() 33 | 34 | exception = response.compound_exception() 35 | self.assertTrue(ValueError in exception.exception_types) 36 | 37 | def test_set(self): 38 | results = GatheredResults([]) 39 | with self.assertRaises(AttributeError): 40 | results.results = None 41 | 42 | def test_set_2(self): 43 | results = asyncio.run(gather(good(), bad())) 44 | with self.assertRaises(AttributeError): 45 | results.results = None 46 | self.assertEqual(len(results.all), 2) 47 | self.assertEqual(len(results.results), 2) 48 | --------------------------------------------------------------------------------