Aiohttp integration (#270)

* Add aiohttp extension module

* Add giphynav-aiohttp app

* Add missing docstrings

* Remove print() call

* Remove not needed import from flask extension tests

* Improve coroutine provider tests

* Add aiohttp extension tests

* Update tox.ini

* Add aiohttp extras

* Try fix Python 3.4 tests

* Try fix 3.6 tests

* Stop running coroutine tests for Python 3.4

* Rename tests

* Remove type hints

* Fix pypy and change python version for coverage job to 3.8

* Fix coveralls job

* Try fix Python 3.4, 3.5 tests

* Make coverage job to run 3.5+ tests

* Add tests

* Add readme

* Update the readmes

* Add API docs

* Add API docs page

* Update changelog
This commit is contained in:
Roman Mogylatov 2020-07-28 19:19:05 -04:00 committed by GitHub
parent bed547cc91
commit e0d81c2d28
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 580 additions and 23 deletions

View File

@ -3,7 +3,7 @@ dist: xenial
language: python
jobs:
include:
- python: 3.6
- python: 3.8
env: TOXENV=coveralls DEPENDENCY_INJECTOR_DEBUG_MODE=1
install:
- pip install tox

9
docs/api/aiohttpext.rst Normal file
View File

@ -0,0 +1,9 @@
dependency_injector.ext.aiohttp
===============================
.. automodule:: dependency_injector.ext.aiohttp
:members:
:special-members:
.. disqus::

View File

@ -8,4 +8,5 @@ API Documentation
providers
containers
errors
aiohttpext
flaskext

View File

@ -7,6 +7,11 @@ that were made in every particular version.
From version 0.7.6 *Dependency Injector* framework strictly
follows `Semantic versioning`_
Development version
-------------------
- Add ``Aiohttp`` integration module ``dependency_injector.ext.aiohttp``.
- Add ``Aiohttp`` + ``Dependency Injector`` example ``giphynav-aiohttp``.
3.23.2
------
- Fix ``Flask`` tutorial code issues, typos and change some wording.
@ -48,7 +53,7 @@ follows `Semantic versioning`_
3.20.0
------
- Add ``Flask`` integration module ``dependency_injector.flask.ext``.
- Add ``Flask`` integration module ``dependency_injector.ext.flask``.
- Add ``Flask`` + ``Dependency Injector`` example ``ghnav-flask``.
- Add ``Factory.provides`` attribute. It is an alias to the ``Factory.cls``.
- New README.

View File

@ -68,7 +68,7 @@ After that visit http://127.0.0.1:5000/ in your browser.
Test
----
This application comes with unit tests.
This application comes with the unit tests.
To run the tests do:

View File

@ -0,0 +1,117 @@
Aiohttp Dependency Injection Example
====================================
Application ``giphynavigator`` is a `Aiohttp <https://docs.aiohttp.org/>`_ +
`Dependency Injector <http://python-dependency-injector.ets-labs.org/>`_ application.
Run
---
Create virtual environment:
.. code-block:: bash
virtualenv venv
. venv/bin/activate
Install requirements:
.. code-block:: bash
pip install -r requirements.txt
To run the application do:
.. code-block:: bash
export GIPHY_API_KEY=wBJ2wZG7SRqfrU9nPgPiWvORmloDyuL0
adev runserver giphynavigator/application.py --livereload
The output should be something like:
.. code-block::
[18:52:59] Starting aux server at http://localhost:8001 ◆
[18:52:59] Starting dev server at http://localhost:8000 ●
After that visit http://127.0.0.1:8000/ in your browser or use CLI command (``curl``, ``httpie``,
etc). You should see something like:
.. code-block:: json
{
"query": "Dependency Injector",
"limit": 10,
"gifs": [
{
"url": "https://giphy.com/gifs/boxes-dependent-swbf2-6Eo7KzABxgJMY"
},
{
"url": "https://giphy.com/gifs/depends-J56qCcOhk6hKE"
},
{
"url": "https://giphy.com/gifs/web-series-ccstudios-bro-dependent-1lhU8KAVwmVVu"
},
{
"url": "https://giphy.com/gifs/TheBoysTV-friends-friend-weneedeachother-XxR9qcIwcf5Jq404Sx"
},
{
"url": "https://giphy.com/gifs/netflix-a-series-of-unfortunate-events-asoue-9rgeQXbwoK53pcxn7f"
},
{
"url": "https://giphy.com/gifs/black-and-white-sad-skins-Hs4YzLs2zJuLu"
},
{
"url": "https://giphy.com/gifs/always-there-for-you-i-am-here-PlayjhCco9jHBYrd9w"
},
{
"url": "https://giphy.com/gifs/stream-famous-dollar-YT2dvOByEwXCdoYiA1"
},
{
"url": "https://giphy.com/gifs/i-love-you-there-for-am-1BhGzgpZXYWwWMAGB1"
},
{
"url": "https://giphy.com/gifs/life-like-twerk-9hlnWxjHqmH28"
}
]
}
.. note::
To create your own Giphy API key follow this
`guide <https://support.giphy.com/hc/en-us/articles/360020283431-Request-A-GIPHY-API-Key>`_.
Test
----
This application comes with the unit tests.
To run the tests do:
.. code-block:: bash
py.test giphynavigator/tests.py --cov=giphynavigator
The output should be something like:
.. code-block::
platform darwin -- Python 3.8.3, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
plugins: cov-2.10.0, aiohttp-0.3.0, asyncio-0.14.0
collected 3 items
giphynavigator/tests.py ... [100%]
---------- coverage: platform darwin, python 3.8.3-final-0 -----------
Name Stmts Miss Cover
---------------------------------------------------
giphynavigator/__init__.py 0 0 100%
giphynavigator/__main__.py 5 5 0%
giphynavigator/application.py 10 0 100%
giphynavigator/containers.py 10 0 100%
giphynavigator/giphy.py 16 11 31%
giphynavigator/services.py 9 1 89%
giphynavigator/tests.py 35 0 100%
giphynavigator/views.py 7 0 100%
---------------------------------------------------
TOTAL 92 17 82%

View File

@ -0,0 +1,5 @@
giphy:
request_timeout: 10
search:
default_query: "Dependency Injector"
default_limit: 10

View File

@ -0,0 +1 @@
"""Top-level package."""

View File

@ -0,0 +1,10 @@
"""Main module."""
from aiohttp import web
from .application import create_app
if __name__ == '__main__':
app = create_app()
web.run_app(app)

View File

@ -0,0 +1,21 @@
"""Application module."""
from aiohttp import web
from .containers import ApplicationContainer
def create_app():
"""Create and return Flask application."""
container = ApplicationContainer()
container.config.from_yaml('config.yml')
container.config.giphy.api_key.from_env('GIPHY_API_KEY')
app: web.Application = container.app()
app.container = container
app.add_routes([
web.get('/', container.index_view.as_view()),
])
return app

View File

@ -0,0 +1,33 @@
"""Application containers module."""
from dependency_injector import containers, providers
from dependency_injector.ext import aiohttp
from aiohttp import web
from . import giphy, services, views
class ApplicationContainer(containers.DeclarativeContainer):
"""Application container."""
app = aiohttp.Application(web.Application)
config = providers.Configuration()
giphy_client = providers.Factory(
giphy.GiphyClient,
api_key=config.giphy.api_key,
timeout=config.giphy.request_timeout,
)
search_service = providers.Factory(
services.SearchService,
giphy_client=giphy_client,
)
index_view = aiohttp.View(
views.index,
search_service=search_service,
default_query=config.search.default_query,
default_limit=config.search.default_limit,
)

View File

@ -0,0 +1,29 @@
"""Giphy client module."""
from aiohttp import ClientSession, ClientTimeout
class GiphyClient:
API_URL = 'http://api.giphy.com/v1'
def __init__(self, api_key, timeout):
self._api_key = api_key
self._timeout = ClientTimeout(timeout)
async def search(self, query, limit):
"""Make search API call and return result."""
if not query:
return []
url = f'{self.API_URL}/gifs/search'
params = {
'q': query,
'api_key': self._api_key,
'limit': limit,
}
async with ClientSession(timeout=self._timeout) as session:
async with session.get(url, params=params) as response:
if response.status != 200:
response.raise_for_status()
return await response.json()

View File

@ -0,0 +1,18 @@
"""Services module."""
from .giphy import GiphyClient
class SearchService:
def __init__(self, giphy_client: GiphyClient):
self._giphy_client = giphy_client
async def search(self, query, limit):
"""Search for gifs and return formatted data."""
if not query:
return []
result = await self._giphy_client.search(query, limit)
return [{'url': gif['url']} for gif in result['data']]

View File

@ -0,0 +1,77 @@
"""Tests module."""
from unittest import mock
import pytest
from giphynavigator.application import create_app
from giphynavigator.giphy import GiphyClient
@pytest.fixture
def app():
return create_app()
@pytest.fixture
def client(app, aiohttp_client, loop):
return loop.run_until_complete(aiohttp_client(app))
async def test_index(client, app):
giphy_client_mock = mock.AsyncMock(spec=GiphyClient)
giphy_client_mock.search.return_value = {
'data': [
{'url': 'https://giphy/gif1.gif'},
{'url': 'https://giphy/gif2.gif'},
],
}
with app.container.giphy_client.override(giphy_client_mock):
response = await client.get(
'/',
params={
'query': 'test',
'limit': 10,
},
)
assert response.status == 200
data = await response.json()
assert data == {
'query': 'test',
'limit': 10,
'gifs': [
{'url': 'https://giphy/gif1.gif'},
{'url': 'https://giphy/gif2.gif'},
],
}
async def test_index_no_data(client, app):
giphy_client_mock = mock.AsyncMock(spec=GiphyClient)
giphy_client_mock.search.return_value = {
'data': [],
}
with app.container.giphy_client.override(giphy_client_mock):
response = await client.get('/')
assert response.status == 200
data = await response.json()
assert data['gifs'] == []
async def test_index_default_params(client, app):
giphy_client_mock = mock.AsyncMock(spec=GiphyClient)
giphy_client_mock.search.return_value = {
'data': [],
}
with app.container.giphy_client.override(giphy_client_mock):
response = await client.get('/')
assert response.status == 200
data = await response.json()
assert data['query'] == app.container.config.search.default_query()
assert data['limit'] == app.container.config.search.default_limit()

View File

@ -0,0 +1,25 @@
"""Views module."""
from aiohttp import web
from .services import SearchService
async def index(
request: web.Request,
search_service: SearchService,
default_query: str,
default_limit: int,
) -> web.Response:
query = request.query.get('query', default_query)
limit = int(request.query.get('limit', default_limit))
gifs = await search_service.search(query, limit)
return web.json_response(
{
'query': query,
'limit': limit,
'gifs': gifs,
},
)

View File

@ -0,0 +1,6 @@
dependency-injector
aiohttp
aiohttp-devtools
pyyaml
pytest-aiohttp
pytest-cov

View File

@ -1 +1,2 @@
flask
aiohttp

View File

@ -67,6 +67,9 @@ setup(name='dependency-injector',
'flask': [
'flask',
],
'aiohttp': [
'aiohttp',
],
},
zip_safe=True,
license='BSD New',

View File

@ -0,0 +1,46 @@
"""Aiohttp extension module."""
from __future__ import absolute_import
import functools
from dependency_injector import providers
class Application(providers.Singleton):
"""Aiohttp application provider."""
class Extension(providers.Singleton):
"""Aiohttp extension provider."""
class Middleware(providers.DelegatedCallable):
"""Aiohttp middleware provider."""
__middleware_version__ = 1
class MiddlewareFactory(providers.Factory):
"""Aiohttp middleware factory provider."""
class View(providers.Callable):
"""Aiohttp view provider."""
def as_view(self):
"""Return aiohttp view function."""
@functools.wraps(self.provides)
async def _view(request, *args, **kwargs):
return await self.__call__(request, *args, **kwargs)
return _view
class ClassBasedView(providers.Factory):
"""Aiohttp class-based view provider."""
def as_view(self):
"""Return aiohttp view function."""
async def _view(request, *args, **kwargs):
return await self.__call__(request, *args, **kwargs)
return _view

View File

@ -0,0 +1,93 @@
"""Dependency injector Aiohttp extension unit tests."""
from aiohttp import web
from aiohttp.test_utils import AioHTTPTestCase, unittest_run_loop
from dependency_injector import containers, providers
from dependency_injector.ext import aiohttp
async def index(_):
return web.Response(text='Hello World!')
async def test(_):
return web.Response(text='Test!')
class Test(web.View):
async def get(self):
return web.Response(text='Test class-based!')
@web.middleware
async def middleware(request, handler):
resp = await handler(request)
resp.text = resp.text + ' wink1'
return resp
def middleware_factory(text):
@web.middleware
async def sample_middleware(request, handler):
resp = await handler(request)
resp.text = resp.text + text
return resp
return sample_middleware
class ApplicationContainer(containers.DeclarativeContainer):
app = aiohttp.Application(
web.Application,
middlewares=providers.List(
aiohttp.Middleware(middleware),
aiohttp.MiddlewareFactory(middleware_factory, text=' wink2'),
),
)
index_view = aiohttp.View(index)
test_view = aiohttp.View(test)
test_class_view = aiohttp.ClassBasedView(Test)
class ApplicationTests(AioHTTPTestCase):
async def get_application(self):
"""
Override the get_app method to return your application.
"""
container = ApplicationContainer()
app = container.app()
app.container = container
app.add_routes([
web.get('/', container.index_view.as_view()),
web.get('/test', container.test_view.as_view(), name='test'),
web.get('/test-class', container.test_class_view.as_view()),
])
return app
@unittest_run_loop
async def test_index(self):
response = await self.client.get('/')
self.assertEqual(response.status, 200)
self.assertEqual(await response.text(), 'Hello World! wink2 wink1')
@unittest_run_loop
async def test_test(self):
response = await self.client.get('/test')
self.assertEqual(response.status, 200)
self.assertEqual(await response.text(), 'Test! wink2 wink1')
@unittest_run_loop
async def test_test_class_based(self):
response = await self.client.get('/test-class')
self.assertEqual(response.status, 200)
self.assertEqual(await response.text(), 'Test class-based! wink2 wink1')
@unittest_run_loop
async def test_endpoints(self):
self.assertEqual(str(self.app.router['test'].url_for()), '/test')

View File

@ -4,7 +4,7 @@ import unittest2 as unittest
from flask import Flask, url_for
from flask.views import MethodView
from dependency_injector import containers, providers
from dependency_injector import containers
from dependency_injector.ext import flask

View File

@ -1,6 +1,9 @@
"""Dependency injector coroutine providers unit tests."""
import asyncio
import contextlib
import sys
import gc
import unittest2 as unittest
@ -10,20 +13,65 @@ from dependency_injector import (
)
@asyncio.coroutine
def _example(arg1, arg2, arg3, arg4):
async def _example(arg1, arg2, arg3, arg4):
future = asyncio.Future()
future.set_result(None)
yield from future
await future
return arg1, arg2, arg3, arg4
def _run(coro):
def run(main):
loop = asyncio.get_event_loop()
return loop.run_until_complete(coro)
return loop.run_until_complete(main)
class CoroutineTests(unittest.TestCase):
def setup_test_loop(
loop_factory=asyncio.new_event_loop
) -> asyncio.AbstractEventLoop:
loop = loop_factory()
try:
module = loop.__class__.__module__
skip_watcher = 'uvloop' in module
except AttributeError: # pragma: no cover
# Just in case
skip_watcher = True
asyncio.set_event_loop(loop)
if sys.platform != "win32" and not skip_watcher:
policy = asyncio.get_event_loop_policy()
watcher = asyncio.SafeChildWatcher() # type: ignore
watcher.attach_loop(loop)
with contextlib.suppress(NotImplementedError):
policy.set_child_watcher(watcher)
return loop
def teardown_test_loop(loop: asyncio.AbstractEventLoop,
fast: bool=False) -> None:
closed = loop.is_closed()
if not closed:
loop.call_soon(loop.stop)
loop.run_forever()
loop.close()
if not fast:
gc.collect()
asyncio.set_event_loop(None)
class AsyncTestCase(unittest.TestCase):
def setUp(self):
self.loop = setup_test_loop()
def tearDown(self):
teardown_test_loop(self.loop)
def _run(self, f):
return self.loop.run_until_complete(f)
class CoroutineTests(AsyncTestCase):
def test_init_with_coroutine(self):
self.assertTrue(providers.Coroutine(_example))
@ -33,34 +81,34 @@ class CoroutineTests(unittest.TestCase):
def test_call_with_positional_args(self):
provider = providers.Coroutine(_example, 1, 2, 3, 4)
self.assertTupleEqual(_run(provider()), (1, 2, 3, 4))
self.assertTupleEqual(self._run(provider()), (1, 2, 3, 4))
def test_call_with_keyword_args(self):
provider = providers.Coroutine(_example,
arg1=1, arg2=2, arg3=3, arg4=4)
self.assertTupleEqual(_run(provider()), (1, 2, 3, 4))
self.assertTupleEqual(self._run(provider()), (1, 2, 3, 4))
def test_call_with_positional_and_keyword_args(self):
provider = providers.Coroutine(_example,
1, 2,
arg3=3, arg4=4)
self.assertTupleEqual(_run(provider()), (1, 2, 3, 4))
self.assertTupleEqual(run(provider()), (1, 2, 3, 4))
def test_call_with_context_args(self):
provider = providers.Coroutine(_example, 1, 2)
self.assertTupleEqual(_run(provider(3, 4)), (1, 2, 3, 4))
self.assertTupleEqual(self._run(provider(3, 4)), (1, 2, 3, 4))
def test_call_with_context_kwargs(self):
provider = providers.Coroutine(_example, arg1=1)
self.assertTupleEqual(
_run(provider(arg2=2, arg3=3, arg4=4)),
self._run(provider(arg2=2, arg3=3, arg4=4)),
(1, 2, 3, 4),
)
def test_call_with_context_args_and_kwargs(self):
provider = providers.Coroutine(_example, 1)
self.assertTupleEqual(
_run(provider(2, arg3=3, arg4=4)),
self._run(provider(2, arg3=3, arg4=4)),
(1, 2, 3, 4),
)
@ -69,7 +117,7 @@ class CoroutineTests(unittest.TestCase):
.add_args(1, 2) \
.add_kwargs(arg3=3, arg4=4)
self.assertTupleEqual(_run(provider()), (1, 2, 3, 4))
self.assertTupleEqual(self._run(provider()), (1, 2, 3, 4))
def test_set_args(self):
provider = providers.Coroutine(_example) \
@ -213,7 +261,7 @@ class DelegatedCoroutineTests(unittest.TestCase):
hex(id(provider))))
class AbstractCoroutineTests(unittest.TestCase):
class AbstractCoroutineTests(AsyncTestCase):
def test_inheritance(self):
self.assertIsInstance(providers.AbstractCoroutine(_example),
@ -227,7 +275,7 @@ class AbstractCoroutineTests(unittest.TestCase):
provider = providers.AbstractCoroutine(_abstract_example)
provider.override(providers.Coroutine(_example))
self.assertTrue(_run(provider(1, 2, 3, 4)), (1, 2, 3, 4))
self.assertTrue(self._run(provider(1, 2, 3, 4)), (1, 2, 3, 4))
def test_call_overridden_by_delegated_coroutine(self):
@asyncio.coroutine
@ -237,7 +285,7 @@ class AbstractCoroutineTests(unittest.TestCase):
provider = providers.AbstractCoroutine(_abstract_example)
provider.override(providers.DelegatedCoroutine(_example))
self.assertTrue(_run(provider(1, 2, 3, 4)), (1, 2, 3, 4))
self.assertTrue(self._run(provider(1, 2, 3, 4)), (1, 2, 3, 4))
def test_call_not_overridden(self):
provider = providers.AbstractCoroutine(_example)

15
tox.ini
View File

@ -8,12 +8,13 @@ deps=
extras=
yaml
flask
aiohttp
commands=
unit2 discover -s tests/unit -p test_*_py3.py
unit2 discover -s tests/unit -p test_*_py3*.py
[testenv:coveralls]
passenv=TRAVIS TRAVIS_JOB_ID TRAVIS_BRANCH DEPENDENCY_INJECTOR_DEBUG_MODE
basepython=python3.6
basepython=python3.8
usedevelop=True
deps=
{[testenv]deps}
@ -22,19 +23,27 @@ deps=
coveralls
commands=
coverage erase
coverage run --rcfile=./.coveragerc -m unittest2 discover -s tests/unit/ -p test_*_py3.py
coverage run --rcfile=./.coveragerc -m unittest2 discover -s tests/unit/ -p test_*_py35.py
coverage report --rcfile=./.coveragerc
coveralls
[testenv:py27]
extras=
yaml
flask
commands=
unit2 discover -s tests/unit -p test_*_py2_py3.py
[testenv:py34]
commands=
unit2 discover -s tests/unit -p test_*_py3.py
extras=
flask
[testenv:pypy]
extras=
yaml
flask
commands=
unit2 discover -s tests/unit -p test_*_py2_py3.py