├── tests ├── __init__.py ├── test_decorators.py ├── conftest.py ├── test_names.py ├── test_autouse.py ├── test_discover_modules.py ├── test_override_provider.py ├── test_discover_default.py ├── test_provider_factories.py ├── test_nested_providers.py ├── test_async_providers.py ├── test_useprovider.py ├── test_sessions.py ├── test_context_providers.py ├── test_generator_providers.py ├── test_scopes.py └── test_consumers.py ├── aiodine ├── scopes.py ├── datatypes.py ├── sessions.py ├── __init__.py ├── exceptions.py ├── compat.py ├── consumers.py ├── store.py └── providers.py ├── requirements.txt ├── setup.cfg ├── .gitignore ├── pylintrc ├── .bumpversion.cfg ├── .coveragerc ├── CONTRIBUTING.md ├── scripts ├── bumpversion.sh └── changelog_bump.py ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── LICENSE ├── setup.py ├── .travis.yml ├── CHANGELOG.md └── README.md /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /aiodine/scopes.py: -------------------------------------------------------------------------------- 1 | FUNCTION = "function" 2 | SESSION = "session" 3 | ALL = {FUNCTION, SESSION} 4 | -------------------------------------------------------------------------------- /aiodine/datatypes.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Awaitable 2 | 3 | CoroutineFunction = Callable[..., Awaitable] 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | 3 | # Development dependencies. 4 | black 5 | bumpversion 6 | mypy 7 | pytest 8 | pytest-asyncio 9 | pytest-cov 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [mypy] 2 | disallow_untyped_defs = True 3 | ignore_missing_imports = True 4 | 5 | [tool:pytest] 6 | addopts = --cov=aiodine --cov=tests --cov-report=term-missing 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__/ 3 | .idea/ 4 | .vscode/ 5 | .pytest_cache/ 6 | .mypy_cache/ 7 | venv/ 8 | *.egg-info 9 | .env 10 | *.db 11 | node_modules/ 12 | .coverage 13 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGE CONTROL] 2 | 3 | disable= 4 | missing-docstring, 5 | bad-continuation, 6 | too-few-public-methods, 7 | too-few-ancestors, 8 | too-many-ancestors 9 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.2.9 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:aiodine/__init__.py] 7 | 8 | [bumpversion:file:setup.py] 9 | 10 | -------------------------------------------------------------------------------- /tests/test_decorators.py: -------------------------------------------------------------------------------- 1 | from aiodine import Store, Provider 2 | 3 | 4 | def test_at_provider_returns_a_provider_object(store: Store): 5 | @store.provider 6 | def example(): 7 | pass 8 | 9 | assert isinstance(example, Provider) 10 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from importlib import reload 2 | 3 | import pytest 4 | 5 | import aiodine 6 | from aiodine import Store 7 | 8 | 9 | @pytest.fixture(params=[Store, lambda: reload(aiodine)]) 10 | def store(request) -> Store: 11 | cls = request.param 12 | return cls() 13 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | source = aiodine 4 | 5 | [report] 6 | exclude_lines = 7 | pragma: no cover 8 | raise NotImplementedError 9 | if __name__ == .__main__.: 10 | if debug: 11 | ignore_errors = True 12 | omit = 13 | setup.py 14 | tests/* 15 | _*.py 16 | -------------------------------------------------------------------------------- /tests/test_names.py: -------------------------------------------------------------------------------- 1 | from aiodine import Store 2 | 3 | 4 | def test_name_is_function_name_by_default(store: Store): 5 | @store.provider 6 | def example(): 7 | pass 8 | 9 | assert example.name == "example" 10 | 11 | 12 | def test_if_name_given_then_used(store: Store): 13 | @store.provider(name="an_example") 14 | def example(): 15 | pass 16 | 17 | assert example.name == "an_example" 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing guidelines 2 | 3 | This project may face period of inactivity, but PRs are much welcome! 4 | 5 | **Note**: feel free to open an issue to discuss ideas before starting to work on a bug fix or new feature. 6 | 7 | ## Development 8 | 9 | - Fork and clone the repository. 10 | - Install dependencies: 11 | 12 | ```bash 13 | python -m venv venv 14 | . venv/bin/activate 15 | pip install -r requirements.txt 16 | ``` 17 | 18 | - Run tests: 19 | 20 | ```bash 21 | pytest 22 | ``` 23 | -------------------------------------------------------------------------------- /tests/test_autouse.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aiodine import Store 3 | 4 | pytestmark = pytest.mark.asyncio 5 | 6 | 7 | async def test_autouse_provider_is_injected_without_declaring_it(store: Store): 8 | used = False 9 | 10 | @store.provider(autouse=True) 11 | async def setup_stuff(): 12 | nonlocal used 13 | used = True 14 | 15 | @store.consumer 16 | async def not_using_setup_stuff(): 17 | pass 18 | 19 | await not_using_setup_stuff() 20 | assert used 21 | -------------------------------------------------------------------------------- /scripts/bumpversion.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Stop if any command fails. 4 | set -e 5 | 6 | ARGS="$@" 7 | CHANGELOG="CHANGELOG.md" 8 | 9 | get () { 10 | bumpversion --dry-run --list $ARGS | grep $1 | sed s,"^.*=",, 11 | } 12 | 13 | CURRENT_VERSION=$(get current_version) 14 | NEW_VERSION=$(get new_version) 15 | 16 | bumpversion "$@" --no-tag --no-commit 17 | python scripts/changelog_bump.py "$CHANGELOG" "v$NEW_VERSION" 18 | 19 | git add -A 20 | git commit -m "Bump version: $CURRENT_VERSION → $NEW_VERSION" 21 | git tag "v$NEW_VERSION" 22 | -------------------------------------------------------------------------------- /aiodine/sessions.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | if TYPE_CHECKING: # pragma: no cover 4 | from .store import Store 5 | 6 | 7 | # NOTE: can't use @asynccontextmanager from contextlib because it was 8 | # only added in Python 3.7. 9 | class Session: 10 | def __init__(self, store: "Store"): 11 | self._store = store 12 | 13 | async def __aenter__(self): 14 | await self._store.enter_session() 15 | return None 16 | 17 | async def __aexit__(self, *args): 18 | await self._store.exit_session() 19 | -------------------------------------------------------------------------------- /aiodine/__init__.py: -------------------------------------------------------------------------------- 1 | from .providers import Provider 2 | from .store import Store 3 | 4 | # pylint: disable=invalid-name 5 | _STORE = Store() 6 | 7 | provider = _STORE.provider 8 | consumer = _STORE.consumer 9 | has_provider = _STORE.has_provider 10 | useprovider = _STORE.useprovider 11 | create_context_provider = _STORE.create_context_provider 12 | providers_module = _STORE.providers_module 13 | empty = _STORE.empty 14 | discover = _STORE.discover 15 | discover_default = _STORE.discover_default 16 | freeze = _STORE.freeze 17 | exit_freeze = _STORE.exit_freeze 18 | session = _STORE.session 19 | enter_session = _STORE.enter_session 20 | exit_session = _STORE.exit_session 21 | 22 | __version__ = "1.2.9" 23 | -------------------------------------------------------------------------------- /tests/test_discover_modules.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | from aiodine import Store 5 | 6 | 7 | @pytest.fixture 8 | def notes_module(store: Store): 9 | class NotesModule: 10 | @store.provider 11 | def pitch(): # pylint: disable=no-method-argument 12 | return "C#" 13 | 14 | sys.modules["notes"] = NotesModule 15 | 16 | 17 | @pytest.mark.asyncio 18 | @pytest.mark.usefixtures("notes_module") 19 | async def test_discover_providers(store: Store): 20 | store.discover("notes") 21 | assert store 22 | assert store.has_provider("pitch") 23 | assert await store.consumer(lambda pitch: 2 * pitch)() == "C#C#" 24 | 25 | 26 | def test_if_module_does_not_exist_then_error(store: Store): 27 | with pytest.raises(ImportError): 28 | store.discover("doesnotexist") 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Implementation ideas** 20 | Any hints or ideas you may have as to how this feature could be implemented. 21 | 22 | **Additional context** 23 | Add any other context or screenshots about the feature request here. 24 | -------------------------------------------------------------------------------- /tests/test_override_provider.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiodine import Store 4 | 5 | pytestmark = pytest.mark.asyncio 6 | 7 | 8 | async def test_override_provider_before_usage(store: Store): 9 | @store.provider 10 | async def hello(): 11 | return "hello" 12 | 13 | @store.provider 14 | async def hello(): 15 | return "HELLO" 16 | 17 | @store.consumer 18 | async def say(hello): 19 | return hello 20 | 21 | assert await say() == "HELLO" 22 | 23 | 24 | async def test_override_provider_after_usage(store: Store): 25 | @store.provider 26 | async def hello(): 27 | return "hello" 28 | 29 | @store.consumer 30 | async def say(hello): 31 | return hello 32 | 33 | @store.provider 34 | async def hello(): 35 | return "HELLO" 36 | 37 | assert await say() == "HELLO" 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Expected behavior** 11 | 12 | 13 | **Actual behavior** 14 | 15 | 16 | **To Reproduce** 17 | 18 | 19 | **Material** 20 | 21 | 22 | **Possible solutions** 23 | 24 | 25 | **Desktop (please complete the following information):** 26 | - OS: 27 | - Python version: 28 | - aiodine version: 29 | 30 | **Additional context** 31 | 32 | -------------------------------------------------------------------------------- /tests/test_discover_default.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | 5 | from aiodine import Store 6 | 7 | 8 | @pytest.fixture 9 | def providers_module(store: Store): 10 | class FixtureConf: 11 | __spec__ = "spam" # module-like 12 | 13 | @store.provider 14 | def example(): # pylint: disable=no-method-argument 15 | return "foo" 16 | 17 | sys.modules[store.providers_module] = FixtureConf 18 | 19 | 20 | def test_default_providers_module(store: Store): 21 | assert store.providers_module == "providerconf" 22 | 23 | 24 | def test_if_no_provider_module_then_ok(store: Store): 25 | store.discover_default() 26 | assert store.empty() 27 | 28 | 29 | @pytest.mark.asyncio 30 | @pytest.mark.usefixtures("providers_module") 31 | async def test_if_provider_module_then_providers_are_loaded(store: Store): 32 | store.discover_default() 33 | assert not store.empty() 34 | assert store.has_provider("example") 35 | assert await store.consumer(lambda example: 2 * example)() == "foofoo" 36 | -------------------------------------------------------------------------------- /aiodine/exceptions.py: -------------------------------------------------------------------------------- 1 | class AiodineException(Exception): 2 | """Base exceptions for the aiodine package.""" 3 | 4 | 5 | class ConsumerDeclarationError(AiodineException): 6 | """Base exception for when a consumer is ill-declared.""" 7 | 8 | 9 | class ProviderDeclarationError(AiodineException): 10 | """Base exception for when a provider is ill-declared.""" 11 | 12 | 13 | class RecursiveProviderError(ProviderDeclarationError): 14 | """Raised when two providers depend on each other.""" 15 | 16 | def __init__(self, first: str, second: str): 17 | message = ( 18 | "recursive provider detected: " 19 | f"{first} and {second} depend on each other." 20 | ) 21 | super().__init__(message) 22 | 23 | 24 | class UnknownScope(AiodineException): 25 | """Raised when an unknown scope is used.""" 26 | 27 | 28 | class ProviderDoesNotExist(AiodineException): 29 | """Raised when using an unknown provider.""" 30 | 31 | def __init__(self, name: str): 32 | super().__init__(f"provider {name} does not exist") 33 | -------------------------------------------------------------------------------- /tests/test_provider_factories.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiodine import Store 4 | 5 | pytestmark = pytest.mark.asyncio 6 | 7 | 8 | async def test_provider_factory_pattern(store: Store): 9 | @store.provider 10 | async def note(): 11 | async def _get_note(pk: int): 12 | return {"id": pk} 13 | 14 | return _get_note 15 | 16 | @store.consumer 17 | async def get_note(pk: int, note): 18 | return await note(pk) 19 | 20 | assert await get_note(10) == {"id": 10} 21 | 22 | 23 | async def test_generator_provider_factory(store: Store): 24 | setup = False 25 | cleanup = False 26 | 27 | @store.provider 28 | async def note(): 29 | nonlocal setup, cleanup 30 | setup = True 31 | 32 | async def _get_note(pk: int): 33 | return {"id": pk} 34 | 35 | yield _get_note 36 | cleanup = True 37 | 38 | @store.consumer 39 | async def get_note(pk: int, note): 40 | return await note(pk) 41 | 42 | assert await get_note(10) == {"id": 10} 43 | assert setup 44 | assert cleanup 45 | -------------------------------------------------------------------------------- /aiodine/compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | from functools import wraps 4 | from typing import AsyncGenerator, Awaitable, Callable, Generator 5 | 6 | try: # pragma: no cover 7 | from contextlib import AsyncExitStack # pylint: disable=unused-import 8 | except ImportError: # pragma: no cover 9 | from async_exit_stack import AsyncExitStack 10 | 11 | 12 | if sys.version_info < (3, 7): # pragma: no cover 13 | from aiocontextvars import ( # pylint: disable=unused-import, import-error 14 | ContextVar, 15 | Token, 16 | ) 17 | else: # pragma: no cover 18 | from contextvars import ContextVar, Token # pylint: disable=unused-import 19 | 20 | 21 | def wrap_async(func: Callable) -> Callable[..., Awaitable]: 22 | @wraps(func) 23 | async def async_func(*args, **kwargs): 24 | return func(*args, **kwargs) 25 | 26 | return async_func 27 | 28 | 29 | def wrap_generator_async(gen: Generator) -> Callable[..., AsyncGenerator]: 30 | @wraps(gen) 31 | async def async_gen(*args, **kwargs): 32 | for item in gen(*args, **kwargs): 33 | yield item 34 | 35 | return async_gen 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Florimond Manca 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 | -------------------------------------------------------------------------------- /tests/test_nested_providers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiodine import Store 4 | from aiodine.exceptions import RecursiveProviderError 5 | 6 | 7 | pytestmark = pytest.mark.asyncio 8 | 9 | 10 | async def test_provider_uses_provider(store: Store): 11 | with store.exit_freeze(): 12 | 13 | @store.provider 14 | def a(): 15 | return "a" 16 | 17 | @store.provider 18 | def b(a): 19 | return a * 2 20 | 21 | func = store.consumer(lambda b: 2 * b) 22 | assert await func() == "aaaa" 23 | 24 | 25 | async def test_provider_uses_provider_declared_later(store: Store): 26 | with store.exit_freeze(): 27 | 28 | @store.provider 29 | def b(a): 30 | return a * 2 31 | 32 | @store.provider 33 | def a(): 34 | return "a" 35 | 36 | func = store.consumer(lambda b: 2 * b) 37 | assert await func() == "aaaa" 38 | 39 | 40 | async def test_detect_recursive_provider(store: Store): 41 | @store.provider 42 | def b(a): 43 | return a * 2 44 | 45 | with pytest.raises(RecursiveProviderError) as ctx: 46 | 47 | @store.provider 48 | def a(b): 49 | return a * 2 50 | -------------------------------------------------------------------------------- /tests/test_async_providers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from inspect import iscoroutine 3 | 4 | from aiodine import Store, scopes 5 | from aiodine.exceptions import ProviderDeclarationError 6 | 7 | pytestmark = pytest.mark.asyncio 8 | 9 | 10 | async def test_use_async_provider(store: Store): 11 | @store.provider 12 | async def pitch(): 13 | return "C#" 14 | 15 | @store.consumer 16 | def play_sync(pitch): 17 | return 2 * "C#" 18 | 19 | @store.consumer 20 | async def play_async(pitch): 21 | return 2 * "C#" 22 | 23 | assert await play_sync() == "C#C#" 24 | assert await play_async() == "C#C#" 25 | 26 | 27 | async def test_lazy_async_provider(store: Store): 28 | @store.provider(lazy=True) 29 | async def pitch(): 30 | return "C#" 31 | 32 | @store.consumer 33 | async def play(pitch): 34 | assert iscoroutine(pitch) 35 | return 2 * await pitch 36 | 37 | assert await play() == "C#C#" 38 | 39 | 40 | async def test_lazy_provider_must_be_function_scoped(store: Store): 41 | with pytest.raises(ProviderDeclarationError): 42 | 43 | @store.provider(lazy=True, scope=scopes.SESSION) 44 | async def pitch(): 45 | pass 46 | -------------------------------------------------------------------------------- /tests/test_useprovider.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aiodine import Store 3 | from aiodine.exceptions import ProviderDoesNotExist 4 | 5 | pytestmark = pytest.mark.asyncio 6 | 7 | 8 | def declare_provider(store: Store): 9 | @store.provider 10 | async def setup(): 11 | setup.called = True 12 | 13 | setup.called = False 14 | return setup 15 | 16 | 17 | @pytest.mark.parametrize("lazy", (True, False)) 18 | async def test_from_string(store: Store, lazy: bool): 19 | if not lazy: 20 | setup = declare_provider(store) 21 | 22 | @store.consumer 23 | @store.useprovider("setup") 24 | async def consume(): 25 | pass 26 | 27 | if lazy: 28 | setup = declare_provider(store) 29 | 30 | assert not setup.called 31 | await consume() 32 | assert setup.called 33 | 34 | 35 | async def test_from_provider(store: Store): 36 | setup = declare_provider(store) 37 | 38 | @store.consumer 39 | @store.useprovider(setup) 40 | async def consume(): 41 | pass 42 | 43 | assert not setup.called 44 | await consume() 45 | assert setup.called 46 | 47 | 48 | async def test_use_unknown_provider(store: Store): 49 | @store.consumer 50 | @store.useprovider("foo") 51 | async def consume(): 52 | pass 53 | 54 | with pytest.raises(ProviderDoesNotExist): 55 | await consume() 56 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Package setup.""" 2 | 3 | import setuptools 4 | 5 | description = "Async-first dependency injection library for Python" 6 | 7 | with open("README.md", "r") as readme: 8 | long_description = readme.read() 9 | 10 | GITHUB = "https://github.com/bocadilloproject/aiodine" 11 | 12 | setuptools.setup( 13 | name="aiodine", 14 | version="1.2.9", 15 | author="Florimond Manca", 16 | author_email="florimond.manca@gmail.com", 17 | description=description, 18 | long_description=long_description, 19 | long_description_content_type="text/markdown", 20 | packages=setuptools.find_packages(), 21 | install_requires=[ 22 | "async_exit_stack;python_version<'3.7'", 23 | "aiocontextvars;python_version<'3.7'", 24 | ], 25 | python_requires=">=3.6", 26 | url=GITHUB, 27 | license="MIT", 28 | classifiers=[ 29 | "Development Status :: 1 - Planning", 30 | "Intended Audience :: Developers", 31 | "License :: OSI Approved :: MIT License", 32 | "Natural Language :: English", 33 | "Operating System :: OS Independent", 34 | "Programming Language :: Python :: 3 :: Only", 35 | "Programming Language :: Python :: 3.6", 36 | "Programming Language :: Python :: 3.7", 37 | "Framework :: AsyncIO", 38 | "Topic :: Software Development :: Libraries :: Python Modules", 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: xenial 3 | python: "3.6" 4 | cache: pip 5 | 6 | jobs: 7 | include: 8 | - stage: test 9 | script: pytest 10 | after_success: codecov 11 | 12 | - stage: test 13 | python: "3.7" 14 | script: pytest 15 | 16 | - stage: deploy to pypi 17 | if: tag IS present 18 | script: skip 19 | deploy: 20 | - provider: pypi 21 | distributions: sdist bdist_wheel 22 | username: "$PYPI_USERNAME" 23 | password: "$PYPI_PASSWORD" 24 | on: 25 | tags: true 26 | 27 | - stage: create github release 28 | if: tag IS present 29 | script: skip 30 | deploy: 31 | provider: releases 32 | api_key: 33 | secure: d8/OY/iH2FiEUHV0yfTAyWwnat6RJ2GfZfxjvG4N+9XA0XvMvp4WwHILkdLqOF1RWgZjSNEUPbhIFyEeoL82roguNsKRALhlWabNbw3nRh8ECItD01QZZMSW1MyXbZVQtJXJCg5g8NJTRbE8Gf6USK53utcoDi+X+vjVhaxT/loBBp2V8kk0ZwW7s0AeD85TekAYQGjx5KfLKFNxdjvQrsa2ncb6l5Ax/GEjhAW0QZpIqt+qRkMpZGu7507UVXjqLSfebRplZfyfeYHOIVmHIwicNi7TVOvMpUEJX3Wm4ioBkFZ5WttIhppecL61rWXxRwCLrUrWDiVQkQvIa7XUIKhHukBixCFH6cJyvTWnIp0k0UgADCkH8kd8kSKyDHlbtkmkXg/OT9B2Pc431vgMI2Ch1LeUiqKe/GsO+Wl8xbapce6nK0kE+PaM3GrGWCiPKWPvtc0jcbbGMt5/TOT7gNZn/fqQf7Tgaln3Fe17mBZ0XRQ4utLJAP33FPFHEz2uozE5A4Lpt51A4lN/rbfEJlYrYw7hSEc9n7Hu8FD3hGnsy+ONhCFZLn5Pstnsz9KcNbMn8xi+kGS7R+c45wSTTtybx9TJzxHdYiEho/kz5VNqEDe34u9SwbkMpvpPdzsRyzGVuX3Mmqku6IMc234/sLooJ6i4NMBeiqOwICj8Xds= 34 | on: 35 | repo: bocadilloproject/aiodine 36 | -------------------------------------------------------------------------------- /tests/test_sessions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from aiodine import Store 3 | 4 | pytestmark = pytest.mark.asyncio 5 | 6 | 7 | async def test_session_provider_instanciate_on_enter(store: Store): 8 | setup = False 9 | 10 | @store.provider(scope="session") 11 | async def resources(): 12 | nonlocal setup 13 | setup = True 14 | return 15 | 16 | @store.consumer 17 | def consumer(resources: None): 18 | pass 19 | 20 | async with store.session(): 21 | assert setup 22 | 23 | 24 | async def test_session_provider_destroyed_on_exit(store: Store): 25 | @store.provider(scope="session") 26 | async def resources(): 27 | return ["resource"] 28 | 29 | @store.consumer 30 | def consumer(resources: list): 31 | resources.append("other") 32 | return resources 33 | 34 | async with store.session(): 35 | await consumer() 36 | assert await consumer() == ["resource", "other", "other"] 37 | 38 | # Instance was reset. 39 | assert await consumer() == ["resource", "other"] 40 | 41 | 42 | async def test_reuse_session_provider_within_session(store): 43 | @store.provider(scope="session") 44 | async def bar(foo): 45 | return foo 46 | 47 | @store.provider(scope="session") 48 | async def foo(): 49 | return object() 50 | 51 | @store.consumer 52 | def consumer(foo, bar): 53 | return foo, bar 54 | 55 | store.freeze() 56 | async with store.session(): 57 | foo, bar = await consumer() 58 | assert foo is bar 59 | -------------------------------------------------------------------------------- /scripts/changelog_bump.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import sys 4 | from datetime import datetime 5 | from typing import Callable, Match, Optional 6 | 7 | LINK_REGEX = re.compile(r"\[(\w+)\](.*)(v.*)\.\.\.(.*)") 8 | 9 | 10 | def update( 11 | content, 12 | match: Optional[Match], 13 | first_line: Callable, 14 | second_line: Callable, 15 | sep: str = "\n", 16 | ): 17 | assert match is not None 18 | line = content[match.start() : match.end()] 19 | return "".join( 20 | [ 21 | content[: match.start()], 22 | sep.join([first_line(line), second_line(line)]), 23 | content[match.end() :], 24 | ] 25 | ) 26 | 27 | 28 | def bump_changelog(content: str, next_version: str): 29 | # Bump link references 30 | content = update( 31 | content, 32 | match=LINK_REGEX.search(content), 33 | first_line=lambda line: LINK_REGEX.sub( 34 | rf"[\g<1>]\g<2>{next_version}...\g<4>", line 35 | ), 36 | second_line=lambda line: LINK_REGEX.sub( 37 | rf"[{next_version}]\g<2>\g<3>...{next_version}", line 38 | ), 39 | ) 40 | 41 | # Bump sections 42 | today = datetime.now().date() 43 | content = update( 44 | content, 45 | match=re.search(r"## \[Unreleased\]", content), 46 | first_line=lambda line: line, 47 | second_line=lambda line: f"## [{next_version}] - {today}", 48 | sep="\n\n", 49 | ) 50 | 51 | return content 52 | 53 | 54 | def main(path: str, next_version: str): 55 | with open(path, "r") as f: 56 | out = bump_changelog(f.read(), next_version) 57 | with open(path, "w") as f: 58 | f.write(out) 59 | 60 | 61 | if __name__ == "__main__": 62 | main(os.path.join(os.getcwd(), sys.argv[1]), next_version=sys.argv[2]) 63 | -------------------------------------------------------------------------------- /tests/test_context_providers.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep, gather 2 | 3 | import pytest 4 | from aiodine import Store 5 | 6 | # pylint: disable=no-value-for-parameter 7 | 8 | pytestmark = pytest.mark.asyncio 9 | 10 | 11 | async def test_provider_value_is_none_by_default(store: Store): 12 | store.create_context_provider("name") 13 | 14 | @store.consumer 15 | async def get_name(name): 16 | return name 17 | 18 | assert await get_name() is None 19 | 20 | 21 | async def test_assign_value(store: Store): 22 | provider = store.create_context_provider("name") 23 | 24 | @store.consumer 25 | async def get_name(name): 26 | return name 27 | 28 | with provider.assign(name="alice"): 29 | assert await get_name() == "alice" 30 | 31 | assert await get_name() is None 32 | 33 | 34 | async def test_multiple_providers(store: Store): 35 | provider = store.create_context_provider("name", "title") 36 | 37 | @store.consumer 38 | async def get_them(name, title): 39 | return name, title 40 | 41 | assert await get_them() == (None, None) 42 | 43 | with provider.assign(name="alice", title="Slim Fox"): 44 | assert await get_them() == ("alice", "Slim Fox") 45 | 46 | assert await get_them() == (None, None) 47 | 48 | 49 | async def test_multi_client(store: Store): 50 | provider = store.create_context_provider("name") 51 | 52 | @store.consumer 53 | async def get_name(name): 54 | return name 55 | 56 | async def client1(): 57 | with provider.assign(name="alice"): 58 | await sleep(0.01) 59 | # Would get "bob" if multiple clients were not supported. 60 | assert await get_name() == "alice" 61 | 62 | async def client2(): 63 | await sleep(0.005) 64 | with provider.assign(name="bob"): 65 | await sleep(0.01) 66 | 67 | await gather(client1(), client2()) 68 | -------------------------------------------------------------------------------- /tests/test_generator_providers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiodine import Store 4 | 5 | pytestmark = pytest.mark.asyncio 6 | 7 | 8 | async def test_sync_function_yield_provider(store: Store): 9 | setup = False 10 | teardown = False 11 | 12 | @store.provider 13 | def resource(): 14 | nonlocal setup, teardown 15 | setup = True 16 | yield "resource" 17 | teardown = True 18 | 19 | @store.consumer 20 | def consume(resource: str): 21 | return resource.upper() 22 | 23 | assert await consume() == "RESOURCE" 24 | assert setup 25 | assert teardown 26 | 27 | 28 | async def test_async_function_generator_provider(store: Store): 29 | setup = False 30 | teardown = False 31 | 32 | @store.provider 33 | async def resource(): 34 | nonlocal setup, teardown 35 | setup = True 36 | yield "resource" 37 | teardown = True 38 | 39 | @store.consumer 40 | def consume(resource: str): 41 | return resource.upper() 42 | 43 | assert await consume() == "RESOURCE" 44 | assert setup 45 | assert teardown 46 | 47 | 48 | async def test_session_generator_provider(store: Store): 49 | setup = False 50 | teardown = False 51 | 52 | @store.provider(scope="session") 53 | async def resource(): 54 | nonlocal setup, teardown 55 | setup = True 56 | yield "resource" 57 | teardown = True 58 | 59 | @store.consumer 60 | def consume(resource: str): 61 | return resource.upper() 62 | 63 | assert await consume() == "RESOURCE" 64 | assert setup 65 | assert not teardown 66 | 67 | await store.exit_session() 68 | assert teardown 69 | 70 | 71 | async def test_cleanup_even_if_exception_occurred(store: Store): 72 | teardown = False 73 | 74 | @store.provider 75 | async def resource(): 76 | nonlocal teardown 77 | yield "resource" 78 | teardown = True 79 | 80 | @store.consumer 81 | async def consume(resource): 82 | raise ValueError 83 | 84 | with pytest.raises(ValueError): 85 | await consume() 86 | 87 | assert teardown 88 | -------------------------------------------------------------------------------- /tests/test_scopes.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from aiodine import Store, scopes 4 | from aiodine.exceptions import UnknownScope 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "store, expected_scope", 9 | [ 10 | [Store(), scopes.FUNCTION], 11 | [Store(default_scope=scopes.FUNCTION), scopes.FUNCTION], 12 | [Store(default_scope=scopes.SESSION), scopes.SESSION], 13 | ], 14 | ) 15 | def test_default_scope(store, expected_scope): 16 | @store.provider 17 | def items(): 18 | pass 19 | 20 | assert items.scope == expected_scope 21 | 22 | 23 | @pytest.mark.parametrize( 24 | "scope, expected_second_call", 25 | [(scopes.SESSION, [1, 2]), (scopes.FUNCTION, [2])], 26 | ) 27 | @pytest.mark.asyncio 28 | async def test_reuse_of_provided_values( 29 | store: Store, scope, expected_second_call 30 | ): 31 | @store.provider(scope=scope) 32 | def items(): 33 | return [] 34 | 35 | @store.consumer 36 | def add(items, value): 37 | items.append(value) 38 | return items 39 | 40 | assert await add(1) == [1] 41 | assert await add(2) == expected_second_call 42 | 43 | 44 | @pytest.mark.parametrize( 45 | "aliases, scope, expected", 46 | [ 47 | [ 48 | {"foo": scopes.FUNCTION, "sass": scopes.SESSION}, 49 | "foo", 50 | scopes.FUNCTION, 51 | ], 52 | [{"foo": scopes.FUNCTION}, "foo", scopes.FUNCTION], 53 | [{"sass": scopes.SESSION}, "sass", scopes.SESSION], 54 | [{"sass": scopes.SESSION}, scopes.FUNCTION, scopes.FUNCTION], 55 | [{"foo": scopes.FUNCTION}, scopes.SESSION, scopes.SESSION], 56 | ], 57 | ) 58 | @pytest.mark.asyncio 59 | async def test_scope_aliases(aliases, scope, expected): 60 | store = Store(scope_aliases=aliases) 61 | 62 | @store.provider(scope=scope) 63 | def items(): 64 | return [] 65 | 66 | assert items.scope == expected 67 | 68 | @store.consumer 69 | def add(items, value): 70 | items.append(value) 71 | return items 72 | 73 | assert await add(1) == [1] 74 | assert await add(2) == [1, 2] if items.scope == scopes.SESSION else [2] 75 | 76 | 77 | def test_unknown_scope(store: Store): 78 | with pytest.raises(UnknownScope) as ctx: 79 | 80 | @store.provider(scope="blabla") 81 | def items(): 82 | pass 83 | 84 | assert "blabla" in str(ctx.value) 85 | -------------------------------------------------------------------------------- /tests/test_consumers.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import partial 3 | 4 | import pytest 5 | from aiodine import Store 6 | from aiodine.exceptions import ConsumerDeclarationError 7 | 8 | pytestmark = pytest.mark.asyncio 9 | 10 | 11 | async def test_consumer_returns_coroutine_function_like(store: Store): 12 | func = store.consumer(lambda: "test") 13 | coro = func() 14 | assert inspect.isawaitable(coro) 15 | await coro 16 | 17 | 18 | async def test_if_no_provider_decalred_then_behaves_like_func(store: Store): 19 | func = store.consumer(lambda: "test") 20 | assert await func() == "test" 21 | 22 | 23 | async def test_if_provider_does_not_exist_then_missing_argument(store: Store): 24 | @store.provider 25 | def gra(): 26 | return "gra" 27 | 28 | # "gra" exists, but not "arg" 29 | func = store.consumer(lambda arg: 2 * arg) 30 | 31 | with pytest.raises(TypeError): 32 | await func() 33 | 34 | assert await func(10) == 20 35 | 36 | 37 | async def test_if_provider_exists_then_injected(store: Store): 38 | @store.provider 39 | def arg(): 40 | return "foo" 41 | 42 | @store.consumer 43 | def func(arg): 44 | return 2 * arg 45 | 46 | assert await func() == "foofoo" 47 | 48 | 49 | async def test_non_provider_parameters_after_provider_parameters_ok( 50 | store: Store 51 | ): 52 | @store.provider 53 | def pitch(): 54 | return "C#" 55 | 56 | @store.consumer 57 | def play(pitch, duration): 58 | assert pitch == "C#" 59 | return (pitch, duration) 60 | 61 | assert await play(1) == ("C#", 1) 62 | assert await play(duration=1) == ("C#", 1) 63 | 64 | 65 | async def test_non_provider_parameters_before_provider_parameters_ok( 66 | store: Store 67 | ): 68 | @store.provider 69 | def pitch(): 70 | return "C#" 71 | 72 | @store.consumer 73 | def play(duration, pitch): 74 | assert pitch == "C#" 75 | return (pitch, duration) 76 | 77 | assert await play(1) == ("C#", 1) 78 | assert await play(duration=1) == ("C#", 1) 79 | 80 | 81 | async def test_async_consumer(store: Store): 82 | @store.provider 83 | def pitch(): 84 | return "C#" 85 | 86 | @store.consumer 87 | async def play(pitch): 88 | return 2 * pitch 89 | 90 | assert await play() == "C#C#" 91 | 92 | 93 | @pytest.mark.parametrize("is_async", (True, False)) 94 | async def test_class_based_consumer(store: Store, is_async: bool): 95 | @store.provider 96 | def pitch(): 97 | return "C#" 98 | 99 | class Consumer: 100 | if is_async: 101 | 102 | async def __call__(self, pitch: str): 103 | return 2 * pitch 104 | 105 | else: 106 | 107 | def __call__(self, pitch: str): 108 | return 2 * pitch 109 | 110 | consume = Consumer() 111 | consume = store.consumer(consume) 112 | assert await consume() == "C#C#" 113 | 114 | 115 | async def test_method_consumer(store: Store): 116 | @store.provider 117 | async def pitch(): 118 | return "C#" 119 | 120 | class Piano: 121 | async def play(self, pitch): 122 | return 2 * pitch 123 | 124 | piano = Piano() 125 | play = store.consumer(piano.play) 126 | assert await play() == "C#C#" 127 | 128 | 129 | async def test_handle_keyword_only_parameters(store: Store): 130 | @store.provider 131 | async def pitch(): 132 | return "C#" 133 | 134 | @store.consumer 135 | async def play(*, pitch, octave): 136 | return 2 * pitch + str(octave) 137 | 138 | assert await play(octave=2) == "C#C#2" 139 | assert await play(pitch="D", octave=2) == "DD2" 140 | 141 | 142 | async def test_if_wrapper_then_wrapped_must_be_async(store: Store): 143 | @partial 144 | def not_async(): 145 | pass 146 | 147 | with pytest.raises(ConsumerDeclarationError): 148 | store.consumer(not_async) 149 | 150 | 151 | async def test_partial_of_async_function(store: Store): 152 | async def consume(): 153 | return "OK" 154 | 155 | consumer = store.consumer(partial(consume)) 156 | 157 | assert await consumer() == "OK" 158 | 159 | 160 | async def test_keeps_order_of_arguments(store: Store): 161 | @store.consumer 162 | async def consume(a, b): 163 | return a + b 164 | 165 | assert await consume("a", "b") == "ab" 166 | -------------------------------------------------------------------------------- /aiodine/consumers.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import sys 3 | from contextlib import suppress 4 | from functools import WRAPPER_ASSIGNMENTS, partial, update_wrapper 5 | from typing import ( 6 | TYPE_CHECKING, 7 | Callable, 8 | Dict, 9 | List, 10 | NamedTuple, 11 | Optional, 12 | Tuple, 13 | Union, 14 | ) 15 | 16 | from .compat import AsyncExitStack, wrap_async 17 | from .datatypes import CoroutineFunction 18 | from .exceptions import ConsumerDeclarationError 19 | 20 | if TYPE_CHECKING: # pragma: no cover 21 | from .store import Store 22 | from .providers import Provider 23 | 24 | PositionalProviders = List[Tuple[str, "Provider"]] 25 | KeywordProviders = Dict[str, "Provider"] 26 | 27 | # Sentinel for parameters that have no provider. 28 | _NO_PROVIDER = object() 29 | 30 | 31 | class ResolvedProviders(NamedTuple): 32 | 33 | positional: PositionalProviders 34 | keyword: KeywordProviders 35 | external: List["Provider"] 36 | 37 | 38 | WRAPPER_IGNORE = {"__module__"} 39 | if sys.version_info < (3, 7): # pragma: no cover 40 | WRAPPER_IGNORE.add("__qualname__") 41 | 42 | WRAPPER_ASSIGNMENTS = set(WRAPPER_ASSIGNMENTS) - WRAPPER_IGNORE 43 | WRAPPER_SLOTS = {"__wrapped__", *WRAPPER_ASSIGNMENTS} 44 | 45 | 46 | class Consumer: 47 | 48 | __slots__ = ("store", "func", "signature", *WRAPPER_SLOTS) 49 | 50 | def __init__( 51 | self, 52 | store: "Store", 53 | consumer_function: Union[partial, Callable, CoroutineFunction], 54 | ): 55 | self.store = store 56 | 57 | if isinstance(consumer_function, partial): 58 | if not inspect.iscoroutinefunction(consumer_function.func): 59 | raise ConsumerDeclarationError( 60 | "'partial' consumer functions must wrap an async function" 61 | ) 62 | else: 63 | if not inspect.isfunction( 64 | consumer_function 65 | ) and not inspect.ismethod(consumer_function): 66 | assert callable(consumer_function), "consumers must be callable" 67 | consumer_function = consumer_function.__call__ 68 | 69 | if not inspect.iscoroutinefunction(consumer_function): 70 | consumer_function = wrap_async(consumer_function) 71 | 72 | self.func = consumer_function 73 | update_wrapper( 74 | self, self.func, assigned=WRAPPER_ASSIGNMENTS, updated=() 75 | ) 76 | 77 | def resolve(self) -> ResolvedProviders: 78 | positional: PositionalProviders = [] 79 | keyword: KeywordProviders = {} 80 | external = [ 81 | *self.store.autouse_providers.values(), 82 | *self.store.get_used_providers(self.func), 83 | ] 84 | 85 | for name, parameter in inspect.signature(self.func).parameters.items(): 86 | prov: Optional["Provider"] = self.store.providers.get( 87 | name, _NO_PROVIDER 88 | ) 89 | 90 | if parameter.kind == inspect.Parameter.KEYWORD_ONLY: 91 | keyword[name] = prov 92 | else: 93 | positional.append((name, prov)) 94 | 95 | return ResolvedProviders( 96 | positional=positional, keyword=keyword, external=external 97 | ) 98 | 99 | async def __call__(self, *args, **kwargs): 100 | # TODO: cache providers after first call for better performance. 101 | providers = self.resolve() 102 | 103 | async with AsyncExitStack() as stack: 104 | 105 | async def _get_value(prov: "Provider"): 106 | if prov.lazy: 107 | return prov(stack) 108 | return await prov(stack) 109 | 110 | for prov in providers.external: 111 | await _get_value(prov) 112 | 113 | # Create a stack out of the positional arguments. 114 | # Reverse it so we can `.pop()` out of it while 115 | # keeping the final order of arguments. 116 | args = list(reversed(args)) 117 | 118 | injected_args = [] 119 | for name, prov in providers.positional: 120 | if name in kwargs: 121 | # Use values from keyword arguments in priority. 122 | injected_args.append(kwargs.pop(name)) 123 | continue 124 | elif prov is _NO_PROVIDER: 125 | # No provider exists. Use the next positional argument. 126 | with suppress(IndexError): 127 | injected_args.append(args.pop()) 128 | else: 129 | # A provider exists for this argument. Use it! 130 | injected_args.append(await _get_value(prov)) 131 | 132 | injected_kwargs = {} 133 | for name, prov in providers.keyword.items(): 134 | if name in kwargs or prov is _NO_PROVIDER: 135 | with suppress(KeyError): 136 | injected_kwargs[name] = kwargs.pop(name) 137 | else: 138 | injected_kwargs[name] = await _get_value(prov) 139 | 140 | return await self.func(*injected_args, **injected_kwargs) 141 | -------------------------------------------------------------------------------- /aiodine/store.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from contextlib import contextmanager 3 | from functools import partial 4 | from importlib import import_module 5 | from importlib.util import find_spec 6 | from typing import Any, Callable, Dict, Optional, Union 7 | 8 | from . import scopes 9 | from .consumers import Consumer 10 | from .datatypes import CoroutineFunction 11 | from .exceptions import ( 12 | RecursiveProviderError, 13 | UnknownScope, 14 | ProviderDoesNotExist, 15 | ) 16 | from .providers import ContextProvider, Provider, SessionProvider 17 | from .sessions import Session 18 | 19 | DEFAULT_PROVIDER_MODULE = "providerconf" 20 | _MISSING = object() 21 | 22 | 23 | class Store: 24 | 25 | __slots__ = ( 26 | "providers", 27 | "autouse_providers", 28 | "scope_aliases", 29 | "default_scope", 30 | "providers_module", 31 | "session_providers", 32 | ) 33 | 34 | def __init__( 35 | self, 36 | providers_module=DEFAULT_PROVIDER_MODULE, 37 | scope_aliases: Dict[str, str] = None, 38 | default_scope: str = scopes.FUNCTION, 39 | ): 40 | if scope_aliases is None: 41 | scope_aliases = {} 42 | 43 | self.providers: Dict[str, Provider] = {} 44 | self.session_providers: Dict[str, SessionProvider] = {} 45 | self.autouse_providers: Dict[str, Provider] = {} 46 | self.scope_aliases = scope_aliases 47 | self.default_scope = default_scope 48 | self.providers_module = providers_module 49 | 50 | # Inspection. 51 | 52 | def empty(self): 53 | return not self.providers 54 | 55 | def has_provider(self, name: str) -> bool: 56 | return name in self.providers 57 | 58 | def _get(self, name: str, *, default: Any = _MISSING) -> Optional[Provider]: 59 | if default is not _MISSING: 60 | return self.providers.get(name, default) 61 | try: 62 | return self.providers[name] 63 | except KeyError: 64 | raise ProviderDoesNotExist(name) from None 65 | 66 | def _get_providers(self, func: Callable) -> Dict[str, Provider]: 67 | providers = { 68 | param: self._get(param, default=None) 69 | for param in inspect.signature(func).parameters 70 | } 71 | return dict(filter(lambda item: item[1] is not None, providers.items())) 72 | 73 | # Provider discovery. 74 | 75 | def discover_default(self): 76 | if find_spec(self.providers_module) is None: 77 | # Module does not exist. 78 | return 79 | self.discover(self.providers_module) 80 | 81 | @staticmethod 82 | def discover(*module_paths: str): 83 | for module_path in module_paths: 84 | import_module(module_path) 85 | 86 | # Provider registration. 87 | 88 | def provider( 89 | self, 90 | func: Callable = None, 91 | scope: str = None, 92 | name: str = None, 93 | lazy: bool = False, 94 | autouse: bool = False, 95 | ) -> Provider: 96 | if func is None: 97 | return partial( 98 | self.provider, 99 | scope=scope, 100 | name=name, 101 | lazy=lazy, 102 | autouse=autouse, 103 | ) 104 | 105 | if scope is None: 106 | scope = self.default_scope 107 | else: 108 | scope = self.scope_aliases.get(scope, scope) 109 | 110 | if scope not in scopes.ALL: 111 | raise UnknownScope(scope) 112 | 113 | if name is None: 114 | name = func.__name__ 115 | 116 | # NOTE: save the new provider before checking for recursion, 117 | # so that its dependants can detect it as a dependency. 118 | prov = Provider.create( 119 | func, name=name, scope=scope, lazy=lazy, autouse=autouse 120 | ) 121 | self._add(prov) 122 | 123 | self._check_for_recursive_providers(name, func) 124 | 125 | return prov 126 | 127 | def _add(self, prov: Provider): 128 | self.providers[prov.name] = prov 129 | if isinstance(prov, SessionProvider): 130 | self.session_providers[prov.name] = prov 131 | if prov.autouse: 132 | self.autouse_providers[prov.name] = prov 133 | 134 | # Provider recursion check. 135 | 136 | def _check_for_recursive_providers(self, name: str, func: Callable): 137 | for other_name, other in self._get_providers(func).items(): 138 | if name in self._get_providers(other.func): 139 | raise RecursiveProviderError(name, other_name) 140 | 141 | # Consumers. 142 | 143 | def consumer( 144 | self, consumer_function: Union[partial, Callable, CoroutineFunction] 145 | ) -> Consumer: 146 | return Consumer(self, consumer_function) 147 | 148 | # Used providers. 149 | 150 | def useprovider(self, *providers: Union[str, Provider]): 151 | def decorate(func): 152 | func.__useproviders__ = providers 153 | return func 154 | 155 | return decorate 156 | 157 | def get_used_providers(self, func: Callable): 158 | providers = getattr(func, "__useproviders__", []) 159 | return [ 160 | prov if isinstance(prov, Provider) else self._get(prov) 161 | for prov in providers 162 | ] 163 | 164 | # Context providers. 165 | 166 | def create_context_provider(self, *args, **kwargs): 167 | return ContextProvider(self, *args, **kwargs) 168 | 169 | # Provider-in-providers freezing. 170 | 171 | def freeze(self): 172 | for prov in self.providers.values(): 173 | prov.func = self.consumer(prov.func) 174 | 175 | @contextmanager 176 | def exit_freeze(self): 177 | yield 178 | self.freeze() 179 | 180 | # Sessions. 181 | 182 | async def enter_session(self): 183 | for provider in self.session_providers.values(): 184 | await provider.enter_session() 185 | 186 | async def exit_session(self): 187 | for provider in self.session_providers.values(): 188 | await provider.exit_session() 189 | 190 | def session(self): 191 | return Session(self) 192 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [v1.2.9] - 2019-10-15 11 | 12 | ### Fixed 13 | 14 | - Reuse session provider instance within session. (Pull #41) 15 | 16 | ## [v1.2.8] - 2019-09-12 17 | 18 | ### Added 19 | 20 | - Contributing guidelines. 21 | 22 | ### Changed 23 | 24 | - Recommend users to pin dependency to `==1.*`. 25 | 26 | ## [v1.2.7] - 2019-06-19 27 | 28 | ### Fixed 29 | 30 | - Passing providers as strings to `@useprovider` is now lazy: providers will be resolved at runtime. 31 | 32 | ### Changed 33 | 34 | - Passing an unknown provider name to `@useprovider` now results in a `ProviderDoesNotExist` exception raised when calling the consumer. 35 | 36 | ## [v1.2.6] - 2019-05-08 37 | 38 | ### Fixed 39 | 40 | - Session-scoped async generator providers were not correctly handled: they returned the async generator instead of setting and cleaning it up. This has been fixed! 41 | 42 | ## [v1.2.5] - 2019-03-30 43 | 44 | ### Fixed 45 | 46 | - Fix regression on instance method consumers. 47 | 48 | ## [v1.2.4] - 2019-03-30 49 | 50 | ### Added 51 | 52 | - Add support for class-based consumers (must implement `.__call__()`). 53 | 54 | ## [v1.2.3] - 2019-03-29 55 | 56 | ### Added 57 | 58 | - Add support for keyword-only parameters in consumers. 59 | 60 | ## [v1.2.2] - 2019-03-24 61 | 62 | ### Fixed 63 | 64 | - Previously, `ImportError` exceptions were silenced when discovering default providers (e.g. in `providerconf.py`). This lead to unexpected behavior when the providers module exists but raises an `ImportError` itself. We now correctly check whether the providers module _exists_ before importing it normally. 65 | 66 | ## [v1.2.1] - 2019-03-15 67 | 68 | ### Fixed 69 | 70 | - The order of arguments passed to a consumer is now preserved. It was previously reversed. 71 | 72 | ## [v1.2.0] - 2019-03-15 73 | 74 | ### Fixed 75 | 76 | - Providers can now be overridden regardless of their declaration order with respect to consumers that use them. Previously, a provider could only be overridden _before_ it was used in a consumer, which was of limited use. 77 | 78 | ## [v1.1.1] - 2019-03-14 79 | 80 | ### Fixed 81 | 82 | - A bug led `partial` consumer functions wrapping a coroutine function to not be called properly when awaiting the aiodine consumer. This has been fixed. 83 | 84 | ## [v1.1.0] - 2019-03-09 85 | 86 | ### Added 87 | 88 | - Context providers: provide context-local variables to consumers. 89 | 90 | ## [v1.0.1] - 2019-03-03 91 | 92 | ### Fixed 93 | 94 | - Guarantee that finalization code of generator providers gets executed even if an exception occurs in the consumer. 95 | 96 | ## [v1.0.0] - 2019-03-03 97 | 98 | ### Added 99 | 100 | - Use providers whose return value is not important via the `@useprovider` decorator. 101 | - Auto-used providers — activated without having to declare them in consumer parameters. 102 | 103 | ### Fixed 104 | 105 | - Providers declared as keyword-only parameters in consumers are now properly injected. 106 | - Require that Python 3.6+ is installed at the package level. 107 | 108 | ## [v0.2.0] - 2019-03-02 109 | 110 | ### Added 111 | 112 | - Session-scoped generator providers, both sync and async. 113 | - Documentation for the factory provider pattern. 114 | - Session enter/exit utils: `store.enter_session()`, `store.exit_session()`, `async with store.session()`. Allows to manually trigger the setup/cleanup of session-scoped providers. 115 | 116 | ## [v0.1.3] - 2019-03-01 117 | 118 | ### Fixed 119 | 120 | - Parameters are now correctly resolved regardless of the positioning of provider parameters relative to non-provider parameters. 121 | 122 | ## [v0.1.2] - 2019-02-28 123 | 124 | ### Fixed 125 | 126 | - Fix an issue that occured when a consumer was an instance of `functools.partial`. 127 | 128 | ## [v0.1.1] - 2019-02-28 129 | 130 | ### Added 131 | 132 | - Scope aliases: allows a store's `@provider` decorator to accept a scope equivalent to (but different from) one of `function` or `session`. For example: `app -> session`. 133 | - The `providers_module` is now configurable on `Store`. 134 | - The (non-aliased) `default_scope` is now configurable on `Store`. 135 | 136 | ### Changed 137 | 138 | - `Store.empty` is now a callable instead of a property. 139 | 140 | ## [v0.1.0] - 2019-02-28 141 | 142 | Initial release. 143 | 144 | ### Added 145 | 146 | - Sync/async providers. 147 | - Providers are named after their function, unless `name` is given. 148 | - Sync/async consumers. 149 | - `function` and `session` scopes. 150 | - Session-scoped generator providers. 151 | - Lazy async providers (function-scoped only). 152 | - Provider discovery: `@aiodine.provider`, `providerconf.py`, `discover_providers()`. 153 | - Nested providers: providers can consume other providers. 154 | - Use the `aiodine` module directly or create a separate `Store`. 155 | 156 | [unreleased]: https://github.com/bocadilloproject/aiodine/compare/v1.2.9...HEAD 157 | [v1.2.9]: https://github.com/bocadilloproject/aiodine/compare/v1.2.8...v1.2.9 158 | [v1.2.8]: https://github.com/bocadilloproject/aiodine/compare/v1.2.7...v1.2.8 159 | [v1.2.7]: https://github.com/bocadilloproject/aiodine/compare/v1.2.6...v1.2.7 160 | [v1.2.6]: https://github.com/bocadilloproject/aiodine/compare/v1.2.5...v1.2.6 161 | [v1.2.5]: https://github.com/bocadilloproject/aiodine/compare/v1.2.4...v1.2.5 162 | [v1.2.4]: https://github.com/bocadilloproject/aiodine/compare/v1.2.3...v1.2.4 163 | [v1.2.3]: https://github.com/bocadilloproject/aiodine/compare/v1.2.2...v1.2.3 164 | [v1.2.2]: https://github.com/bocadilloproject/aiodine/compare/v1.2.1...v1.2.2 165 | [v1.2.1]: https://github.com/bocadilloproject/aiodine/compare/v1.2.0...v1.2.1 166 | [v1.2.0]: https://github.com/bocadilloproject/aiodine/compare/v1.1.1...v1.2.0 167 | [v1.1.1]: https://github.com/bocadilloproject/aiodine/compare/v1.1.0...v1.1.1 168 | [v1.1.0]: https://github.com/bocadilloproject/aiodine/compare/v1.0.1...v1.1.0 169 | [v1.0.1]: https://github.com/bocadilloproject/aiodine/compare/v1.0.0...v1.0.1 170 | [v1.0.0]: https://github.com/bocadilloproject/aiodine/compare/v0.2.0...v1.0.0 171 | [v0.2.0]: https://github.com/bocadilloproject/aiodine/compare/v0.1.3...v0.2.0 172 | [v0.1.3]: https://github.com/bocadilloproject/aiodine/compare/v0.1.2...v0.1.3 173 | [v0.1.2]: https://github.com/bocadilloproject/aiodine/compare/v0.1.1...v0.1.2 174 | [v0.1.1]: https://github.com/bocadilloproject/aiodine/compare/v0.1.0...v0.1.1 175 | [v0.1.0]: https://github.com/bocadilloproject/aiodine/releases/tag/v0.1.0 176 | -------------------------------------------------------------------------------- /aiodine/providers.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from contextlib import contextmanager, suppress 3 | from functools import partial 4 | from typing import ( 5 | TYPE_CHECKING, 6 | Any, 7 | AsyncGenerator, 8 | Awaitable, 9 | Callable, 10 | Dict, 11 | List, 12 | Optional, 13 | Union, 14 | ) 15 | 16 | from . import scopes 17 | from .compat import ( 18 | AsyncExitStack, 19 | wrap_async, 20 | wrap_generator_async, 21 | ContextVar, 22 | Token, 23 | ) 24 | from .datatypes import CoroutineFunction 25 | from .exceptions import ProviderDeclarationError 26 | 27 | if TYPE_CHECKING: # pragma: no cover 28 | from .store import Store 29 | 30 | 31 | async def _terminate_agen(async_gen: AsyncGenerator): 32 | with suppress(StopAsyncIteration): 33 | await async_gen.asend(None) 34 | 35 | 36 | class Provider: 37 | """Base class for providers. 38 | 39 | This is mostly a wrapper around a provider function, along with 40 | some metadata. 41 | """ 42 | 43 | __slots__ = ("func", "name", "scope", "lazy", "autouse") 44 | 45 | def __init__( 46 | self, func: Callable, name: str, scope: str, lazy: bool, autouse: bool 47 | ): 48 | if lazy and scope != scopes.FUNCTION: 49 | raise ProviderDeclarationError( 50 | "Lazy providers must be function-scoped" 51 | ) 52 | 53 | if inspect.isgeneratorfunction(func): 54 | func = wrap_generator_async(func) 55 | elif inspect.isasyncgenfunction(func): 56 | pass 57 | elif not inspect.iscoroutinefunction(func): 58 | func = wrap_async(func) 59 | 60 | assert inspect.iscoroutinefunction(func) or inspect.isasyncgenfunction( 61 | func 62 | ) 63 | 64 | self.func: Union[AsyncGenerator, CoroutineFunction] = func 65 | self.name = name 66 | self.scope = scope 67 | self.lazy = lazy 68 | self.autouse = autouse 69 | 70 | @classmethod 71 | def create(cls, func, **kwargs) -> "Provider": 72 | """Factory method to build a provider of the appropriate scope.""" 73 | scope: Optional[str] = kwargs.get("scope") 74 | if scope == scopes.SESSION: 75 | return SessionProvider(func, **kwargs) 76 | return FunctionProvider(func, **kwargs) 77 | 78 | # NOTE: the returned value is an awaitable, so we *must not* 79 | # declare this function as `async` — its return value should already be. 80 | def __call__(self, stack: AsyncExitStack) -> Awaitable: 81 | raise NotImplementedError 82 | 83 | 84 | class FunctionProvider(Provider): 85 | """Represents a function-scoped provider. 86 | 87 | Its value is recomputed every time the provider is called. 88 | """ 89 | 90 | def __call__(self, stack: AsyncExitStack) -> Awaitable: 91 | value: Union[Awaitable, AsyncGenerator] = self.func() 92 | 93 | if inspect.isasyncgen(value): 94 | agen = value 95 | # We cannot use `await` in here => define a coroutine function 96 | # and return the (awaitable) coroutine itself. 97 | 98 | async def get_value() -> Any: 99 | # Executes setup + `yield `. 100 | val = await agen.asend(None) 101 | # Registers cleanup to be executed when the stack exits. 102 | stack.push_async_callback(partial(_terminate_agen, agen)) 103 | return val 104 | 105 | value: Awaitable = get_value() 106 | 107 | return value 108 | 109 | 110 | class SessionProvider(Provider): 111 | """Represents a session-scoped provider. 112 | 113 | When called, it builds its instance if necessary and returns it. This 114 | means that the underlying provider is only built once and is reused 115 | across function calls. 116 | """ 117 | 118 | __slots__ = Provider.__slots__ + ("_instance", "_generator") 119 | 120 | def __init__(self, *args, **kwargs): 121 | super().__init__(*args, **kwargs) 122 | self._instance: Optional[Any] = None 123 | self._generator: Optional[AsyncGenerator] = None 124 | 125 | async def enter_session(self): 126 | if self._instance is not None: 127 | return 128 | 129 | value = self.func() 130 | 131 | if inspect.isawaitable(value): 132 | value = await value 133 | 134 | if inspect.isasyncgen(value): 135 | agen = value 136 | value = await agen.asend(None) 137 | self._generator = agen 138 | 139 | self._instance = value 140 | 141 | async def exit_session(self): 142 | if self._generator is not None: 143 | await _terminate_agen(self._generator) 144 | self._generator = None 145 | self._instance = None 146 | 147 | async def _get_instance(self) -> Any: 148 | if self._instance is None: 149 | await self.enter_session() 150 | return self._instance 151 | 152 | def __call__(self, stack: AsyncExitStack) -> Awaitable: 153 | return self._get_instance() 154 | 155 | 156 | class ContextProvider: 157 | """A provider of context-local values. 158 | 159 | This provider is implemented using the ``contextvars`` module. 160 | 161 | Parameters 162 | ---------- 163 | store : Store 164 | *names : str 165 | The name of the variables to provide. For each variable, a 166 | ``ContextVar`` is created and used by a new provider named 167 | after the variable. 168 | """ 169 | 170 | def __init__(self, store: "Store", *names: str): 171 | self._store = store 172 | self._variables: Dict[str, ContextVar] = {} 173 | 174 | for name in names: 175 | self._build_provider(name) 176 | 177 | def _build_provider(self, name): 178 | self._variables[name] = ContextVar(name, default=None) 179 | 180 | async def provider(): 181 | return self._variables[name].get() 182 | 183 | return self._store.provider(name=name)(provider) 184 | 185 | def _set(self, **values: Any) -> List[Token]: 186 | # Set new values for the given variables. 187 | tokens = [] 188 | for name, val in values.items(): 189 | token = self._variables[name].set(val) 190 | tokens.append(token) 191 | return tokens 192 | 193 | def _reset(self, *tokens: Token): 194 | # Reset variables to their previous value using the given tokens. 195 | for token in tokens: 196 | self._variables[token.var.name].reset(token) 197 | 198 | @contextmanager 199 | def assign(self, **values: Any): 200 | """Context manager to assign values to variables. 201 | 202 | Only the variables for the current context are changed. Values for 203 | other contexts are unaffected. 204 | 205 | Variables are reset to their previous value on exit. 206 | 207 | Parameters 208 | ---------- 209 | **values : any 210 | """ 211 | tokens = self._set(**values) 212 | try: 213 | yield 214 | finally: 215 | self._reset(*tokens) 216 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aiodine 2 | 3 | [![python](https://img.shields.io/pypi/pyversions/aiodine.svg?logo=python&logoColor=fed749&colorB=3770a0&label=)](https://www.python.org) 4 | [![pypi](https://img.shields.io/pypi/v/aiodine.svg)][pypi-url] 5 | [![travis](https://img.shields.io/travis/bocadilloproject/aiodine.svg)](https://travis-ci.org/bocadilloproject/aiodine) 6 | [![black](https://img.shields.io/badge/code_style-black-000000.svg)](https://github.com/ambv/black) 7 | [![codecov](https://codecov.io/gh/bocadilloproject/aiodine/branch/master/graph/badge.svg)](https://codecov.io/gh/bocadilloproject/aiodine) 8 | [![license](https://img.shields.io/pypi/l/aiodine.svg)][pypi-url] 9 | 10 | [pypi-url]: https://pypi.org/project/aiodine/ 11 | 12 | aiodine provides async-first [dependency injection][di] in the style of [Pytest fixtures](https://docs.pytest.org/en/latest/fixture.html) for Python 3.6+. 13 | 14 | - [Installation](#installation) 15 | - [Concepts](#concepts) 16 | - [Usage](#usage) 17 | - [FAQ](#faq) 18 | - [Changelog](#changelog) 19 | 20 | ## Installation 21 | 22 | ```bash 23 | pip install "aiodine==1.*" 24 | ``` 25 | 26 | ## Concepts 27 | 28 | aiodine revolves around two concepts: 29 | 30 | - **Providers** are in charge of setting up, returning and optionally cleaning up _resources_. 31 | - **Consumers** can access these resources by declaring the provider as one of their parameters. 32 | 33 | This approach is an implementation of [Dependency Injection][di] and makes providers and consumers: 34 | 35 | - **Explicit**: referencing providers by name on the consumer's signature makes dependencies clear and predictable. 36 | - **Modular**: a provider can itself consume other providers, allowing to build ecosystems of reusable (and replaceable) dependencies. 37 | - **Flexible**: provided values are reused within a given scope, and providers and consumers support a variety of syntaxes (asynchronous/synchronous, function/generator) to make provisioning fun again. 38 | 39 | aiodine is **async-first** in the sense that: 40 | 41 | - It was made to work with coroutine functions and the async/await syntax. 42 | - Consumers can only be called in an asynchronous setting. 43 | - But provider and consumer functions can be regular Python functions and generators too, if only for convenience. 44 | 45 | ## Usage 46 | 47 | ### Providers 48 | 49 | **Providers** make a _resource_ available to consumers within a certain _scope_. They are created by decorating a **provider function** with `@aiodine.provider`. 50 | 51 | Here's a "hello world" provider: 52 | 53 | ```python 54 | import aiodine 55 | 56 | @aiodine.provider 57 | async def hello(): 58 | return "Hello, aiodine!" 59 | ``` 60 | 61 | Providers are available in two **scopes**: 62 | 63 | - `function`: the provider's value is re-computed everytime it is consumed. 64 | - `session`: the provider's value is computed only once (the first time it is consumed) and is reused in subsequent calls. 65 | 66 | By default, providers are function-scoped. 67 | 68 | ### Consumers 69 | 70 | Once a provider has been declared, it can be used by **consumers**. A consumer is built by decorating a **consumer function** with `@aiodine.consumer`. A consumer can declare a provider as one of its parameters and aiodine will inject it at runtime. 71 | 72 | Here's an example consumer: 73 | 74 | ```python 75 | @aiodine.consumer 76 | async def show_friendly_message(hello): 77 | print(hello) 78 | ``` 79 | 80 | All aiodine consumers are asynchronous, so you'll need to run them in an asynchronous context: 81 | 82 | ```python 83 | from asyncio import run 84 | 85 | async def main(): 86 | await show_friendly_message() 87 | 88 | run(main()) # "Hello, aiodine!" 89 | ``` 90 | 91 | Of course, a consumer can declare non-provider parameters too. aiodine is smart enough to figure out which parameters should be injected via providers, and which should be expected from the callee. 92 | 93 | ```python 94 | @aiodine.consumer 95 | async def show_friendly_message(hello, repeat=1): 96 | for _ in range(repeat): 97 | print(hello) 98 | 99 | async def main(): 100 | await show_friendly_message(repeat=10) 101 | ``` 102 | 103 | ### Providers consuming other providers 104 | 105 | Providers are modular in the sense that they can themselves consume other providers. 106 | 107 | For this to work however, providers need to be **frozen** first. This ensures that the dependency graph is correctly resolved regardless of the declaration order. 108 | 109 | ```python 110 | import aiodine 111 | 112 | @aiodine.provider 113 | def email(): 114 | return "user@example.net" 115 | 116 | @aiodine.provider 117 | async def send_email(email): 118 | print(f"Sending email to {email}…") 119 | 120 | aiodine.freeze() # <- Ensures that `send_email` has resolved `email`. 121 | ``` 122 | 123 | **Note**: it is safe to call `.freeze()` multiple times. 124 | 125 | A context manager syntax is also available: 126 | 127 | ```python 128 | import aiodine 129 | 130 | with aiodine.exit_freeze(): 131 | @aiodine.provider 132 | def email(): 133 | return "user@example.net" 134 | 135 | @aiodine.provider 136 | async def send_email(email): 137 | print(f"Sending email to {email}…") 138 | ``` 139 | 140 | ### Generator providers 141 | 142 | Generator providers can be used to perform cleanup (finalization) operations after a provider has gone out of scope. 143 | 144 | ```python 145 | import os 146 | import aiodine 147 | 148 | @aiodine.provider 149 | async def complex_resource(): 150 | print("setting up complex resource…") 151 | yield "complex" 152 | print("cleaning up complex resource…") 153 | ``` 154 | 155 | **Tip**: cleanup code is executed even if an exception occurred in the consumer, so there's no need to surround the `yield` statement with a `try/finally` block. 156 | 157 | **Important**: session-scoped generator providers will only be cleaned up if using them in the context of a session. See [Sessions](#sessions) for details. 158 | 159 | ### Lazy async providers 160 | 161 | Async providers are **eager** by default: their return value is awaited before being injected into the consumer. 162 | 163 | You can mark a provider as **lazy** in order to defer awaiting the provided value to the consumer. This is useful when the provider needs to be conditionally evaluated. 164 | 165 | ```python 166 | from asyncio import sleep 167 | import aiodine 168 | 169 | @aiodine.provider(lazy=True) 170 | async def expensive_io_call(): 171 | await sleep(10) 172 | return 42 173 | 174 | @aiodine.consumer 175 | async def compute(expensive_io_call, cache=None): 176 | if cache: 177 | return cache 178 | return await expensive_io_call 179 | ``` 180 | 181 | ### Factory providers 182 | 183 | Instead of returning a scalar value, factory providers return a _function_. Factory providers are useful to implement reusable providers that accept a variety of inputs. 184 | 185 | > This is a _design pattern_ more than anything else. In fact, there's no extra code in aiodine to support this feature. 186 | 187 | The following example defines a factory provider for a (simulated) database query: 188 | 189 | ```python 190 | import aiodine 191 | 192 | @aiodine.provider(scope="session") 193 | async def notes(): 194 | # Some hard-coded sticky notes. 195 | return [ 196 | {"id": 1, "text": "Groceries"}, 197 | {"id": 2, "text": "Make potatoe smash"}, 198 | ] 199 | 200 | @aiodine.provider 201 | async def get_note(notes): 202 | async def _get_note(pk: int) -> list: 203 | try: 204 | # TODO: fetch from a database instead? 205 | return next(note for note in notes if note["id"] == pk) 206 | except StopIteration: 207 | raise ValueError(f"Note with ID {pk} does not exist.") 208 | 209 | return _get_note 210 | ``` 211 | 212 | Example usage in a consumer: 213 | 214 | ```python 215 | @aiodine.consumer 216 | async def show_note(pk: int, get_note): 217 | print(await get_note(pk)) 218 | ``` 219 | 220 | **Tip**: you can combine factory providers with [generator providers](#generator-providers) to cleanup any resources the factory needs to use. Here's an example that provides temporary files and removes them on cleanup: 221 | 222 | ```python 223 | import os 224 | import aiodine 225 | 226 | @aiodine.provider(scope="session") 227 | def tmpfile(): 228 | files = set() 229 | 230 | async def _create_tmpfile(path: str): 231 | with open(path, "w") as tmp: 232 | files.add(path) 233 | return tmp 234 | 235 | yield _create_tmpfile 236 | 237 | for path in files: 238 | os.remove(path) 239 | ``` 240 | 241 | ### Using providers without declaring them as parameters 242 | 243 | Sometimes, a consumer needs to use a provider but doesn't care about the value it returns. In these situations, you can use the `@useprovider` decorator and skip declaring it as a parameter. 244 | 245 | **Tip**: the `@useprovider` decorator accepts a variable number of providers, which can be given by name or by reference. 246 | 247 | ```python 248 | import os 249 | import aiodine 250 | 251 | @aiodine.provider 252 | def cache(): 253 | os.makedirs("cache", exist_ok=True) 254 | 255 | @aiodine.provider 256 | def debug_log_file(): 257 | with open("debug.log", "w"): 258 | pass 259 | yield 260 | os.remove("debug.log") 261 | 262 | @aiodine.consumer 263 | @aiodine.useprovider("cache", debug_log_file) 264 | async def build_index(): 265 | ... 266 | ``` 267 | 268 | ### Auto-used providers 269 | 270 | Auto-used providers are **automatically activated** (within their configured scope) without having to declare them as a parameter in the consumer. 271 | 272 | This can typically spare you from decorating all your consumers with an `@useprovider`. 273 | 274 | For example, the auto-used provider below would result in printing the current date and time to the console every time a consumer is called. 275 | 276 | ```python 277 | import datetime 278 | import aiodine 279 | 280 | @aiodine.provider(autouse=True) 281 | async def logdatetime(): 282 | print(datetime.now()) 283 | ``` 284 | 285 | ### Sessions 286 | 287 | A **session** is the context in which _session providers_ live. 288 | 289 | More specifically, session providers (resp. generator session providers) are instanciated (resp. setup) when entering a session, and destroyed (resp. cleaned up) when exiting the session. 290 | 291 | To enter a session, use: 292 | 293 | ```python 294 | await aiodine.enter_session() 295 | ``` 296 | 297 | To exit it: 298 | 299 | ```python 300 | await aiodine.exit_session() 301 | ``` 302 | 303 | An async context manager syntax is also available: 304 | 305 | ```python 306 | async with aiodine.session(): 307 | ... 308 | ``` 309 | 310 | ### Context providers 311 | 312 | > **WARNING**: this is an experimental feature. 313 | 314 | Context providers were introduced to solve the problem of injecting **context-local resources**. These resources are typically undefined at the time of provider declaration, but become well-defined when entering some kind of **context**. 315 | 316 | This may sound abstract, so let's see an example before showing the usage of context providers. 317 | 318 | #### Example 319 | 320 | Let's say we're in a restaurant. There, a waiter executes orders submitted by customers. Each customer is given an `Order` object which they can `.write()` their desired menu items to. 321 | 322 | In aiodine terminilogy, the waiter is the [provider](#providers) of the order, and the customer is a [consumer](#consumers). 323 | 324 | During service, the waiter needs to listen to new customers, create a new `Order` object, provide it to the customer, execute the order as written by the customer, and destroy the executed order. 325 | 326 | So, in this example, the **context** spans from when an order is created to when it is destroyed, and is specific to a given customer. 327 | 328 | Here's what code simulating this situation on the waiter's side may look like: 329 | 330 | ```python 331 | from asyncio import Queue 332 | 333 | import aiodine 334 | 335 | class Order: 336 | def write(self, item: str): 337 | ... 338 | 339 | class Waiter: 340 | def __init__(self): 341 | self._order = None 342 | self.queue = Queue() 343 | 344 | # Create an `order` provider for customers to use. 345 | # NOTE: the actually provided value is not defined yet! 346 | @aiodine.provider 347 | def order(): 348 | return self._order 349 | 350 | async def _execute(self, order: Order): 351 | ... 352 | 353 | async def _serve(self, customer): 354 | # NOTE: we've now entered the *context* of serving 355 | # a particular customer. 356 | 357 | # Create a new order that the customer can 358 | # via the `order` provider. 359 | self._order = Order() 360 | 361 | await customer() 362 | 363 | # Execute the order and destroy it. 364 | await self._execute(self._order) 365 | self._order = None 366 | 367 | async def start(self): 368 | while True: 369 | customer = await self.queue.get() 370 | await self._serve(customer) 371 | ``` 372 | 373 | It's important to note that customers can do _anything_ with the order. In particular, they may take some time to think about what they are going to order. In the meantime, the server will be listening to other customer calls. In this sense, this situation is an _asynchronous_ one. 374 | 375 | An example customer code may look like this: 376 | 377 | ```python 378 | from asyncio import sleep 379 | 380 | @aiodine.consumer 381 | def alice(order: Order): 382 | # Pondering while looking at the menu… 383 | await sleep(10) 384 | order.write("Pizza Margheritta") 385 | ``` 386 | 387 | Let's reflect on this for a second. Have you noticed that the waiter holds only _one_ reference to an `Order`? This means that the code works fine as long as only _one_ customer is served at a time. 388 | 389 | But what if another customer, say `bob`, comes along while `alice` is thinking about what she'll order? With the current implementation, the waiter will simply _forget_ about `alice`'s order, and end up executing `bob`'s order twice. In short: we'll encounter a **race condition**. 390 | 391 | By using a context provider, we transparently turn the waiter's `order` into a [context variable][contextvars] (a.k.a. `ContextVar`). It is local to the context of each customer, which solves the race condition. 392 | 393 | [contextvars]: https://docs.python.org/3/library/contextvars.html 394 | 395 | Here's how the code would then look like: 396 | 397 | ```python 398 | import aiodine 399 | 400 | class Waiter: 401 | def __init__(self): 402 | self.queue = Queue() 403 | self.provider = aiodine.create_context_provider("order") 404 | 405 | async def _execute(self, order: Order): 406 | ... 407 | 408 | async def _serve(self, customer): 409 | order = Order() 410 | with self.provider.assign(order=order): 411 | await customer() 412 | await self._execute(order) 413 | 414 | async def start(self): 415 | while True: 416 | customer = await self.queue.get() 417 | await self._serve(customer) 418 | ``` 419 | 420 | Note: 421 | 422 | - Customers can use the `order` provider just like before. In fact, it was created when calling `.create_context_provider()`. 423 | - The `order` is now **context-local**, i.e. its value won't be forgotten or scrambled if other customers come and make orders concurrently. 424 | 425 | This situation may look trivial to some, but it is likely to be found in client/server architectures, including in web frameworks. 426 | 427 | #### Usage 428 | 429 | To create a context provider, use `aiodine.create_context_provider()`. This method accepts a variable number of arguments and returns a `ContextProvider`. Each argument is used as the name of a new [`@provider`](#providers) which provides the contents of a [`ContextVar`][contextvars] object. 430 | 431 | ```python 432 | import aiodine 433 | 434 | provider = aiodine.create_context_provider("first_name", "last_name") 435 | ``` 436 | 437 | Each context variable contains `None` initially. This means that consumers will receive `None` — unless they are called within the context of an `.assign()` block: 438 | 439 | ```python 440 | with provider.assign(first_name="alice"): 441 | # Consumers called in this block will receive `"alice"` 442 | # if they consume the `first_name` provider. 443 | ... 444 | ``` 445 | 446 | ## FAQ 447 | 448 | ### Why "aiodine"? 449 | 450 | aiodine contains "aio" as in [asyncio], and "di" as in [Dependency Injection][di]. The last two letters end up making aiodine pronounce like [iodine], the chemical element. 451 | 452 | [asyncio]: https://docs.python.org/3/library/asyncio.html 453 | [di]: https://en.wikipedia.org/wiki/Dependency_injection 454 | [iodine]: https://en.wikipedia.org/wiki/Iodine 455 | 456 | ## Changelog 457 | 458 | See [CHANGELOG.md](https://github.com/bocadilloproject/aiodine/blob/master/CHANGELOG.md). 459 | 460 | ## License 461 | 462 | MIT 463 | --------------------------------------------------------------------------------