python-dependency-injector/docs/tutorials/asyncio-daemon.rst
2024-09-09 22:23:19 -04:00

1034 lines
28 KiB
ReStructuredText
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

.. _asyncio-daemon-tutorial:
Asyncio daemon tutorial
=======================
.. meta::
:keywords: Python,asyncio,Daemon,Monitoring,Tutorial,Education,Web,API,REST API,Example,DI,
Dependency injection,IoC,Inversion of control,Refactoring,Tests,Unit tests,Pytest,
py.test,docker,docker-compose,backend
:description: This tutorial shows how to build an asyncio application following the dependency
injection principle. You will create the monitoring daemon, use docker &
docker-compose, cover the daemon with the unit test and make some refactoring.
This tutorial shows how to build an ``asyncio`` daemon following the dependency injection
principle.
In this tutorial we will use:
- Python 3
- Docker
- Docker-compose
Start from the scratch or jump to the section:
.. contents::
:local:
:backlinks: none
You can find complete project on the
`Github <https://github.com/ets-labs/python-dependency-injector/tree/master/examples/miniapps/asyncio-daemon>`_.
What are we going to build?
---------------------------
We will build a monitoring daemon that monitors web services availability.
The daemon will send the requests to the `example.com <http://example.com>`_ and
`httpbin.org <https://httpbin.org>`_ every couple of seconds. For each successfully completed
response it will log:
- The response code
- The amount of bytes in the response
- The time took to complete the response
.. image:: asyncio-images/diagram.png
Prerequisites
-------------
We will use `Docker <https://www.docker.com/>`_ and
`docker-compose <https://docs.docker.com/compose/>`_ in this tutorial. Let's check the versions:
.. code-block:: bash
docker --version
docker-compose --version
The output should look something like:
.. code-block:: bash
Docker version 20.10.5, build 55c4c88
docker-compose version 1.29.0, build 07737305
.. note::
If you don't have ``Docker`` or ``docker-compose`` you need to install them before proceeding.
Follow these installation guides:
- `Install Docker <https://docs.docker.com/get-docker/>`_
- `Install docker-compose <https://docs.docker.com/compose/install/>`_
The prerequisites are satisfied. Let's get started with the project layout.
Project layout
--------------
Create the project root folder and set it as a working directory:
.. code-block:: bash
mkdir asyncio-daemon-tutorial
cd asyncio-daemon-tutorial
Now we need to create the initial project structure. Create the files and folders following next
layout. All files should be empty for now. We will fill them later.
Initial project layout:
.. code-block:: bash
./
├── monitoringdaemon/
│ ├── __init__.py
│ ├── __main__.py
│ └── containers.py
├── config.yml
├── docker-compose.yml
├── Dockerfile
└── requirements.txt
Initial project layout is ready. We will extend it in the next sections.
Let's proceed to the environment preparation.
Prepare the environment
-----------------------
In this section we are going to prepare the environment for running our daemon.
First we need to specify the project requirements. We will use next packages:
- ``dependency-injector`` - the dependency injection framework
- ``aiohttp`` - the web framework (we need only http client)
- ``pyyaml`` - the YAML files parsing library, used for the reading of the configuration files
- ``pytest`` - the test framework
- ``pytest-asyncio`` - the helper library for the testing of the ``asyncio`` application
- ``pytest-cov`` - the helper library for measuring the test coverage
Put next lines into the ``requirements.txt`` file:
.. code-block:: bash
dependency-injector
aiohttp
pyyaml
pytest
pytest-asyncio
pytest-cov
Second, we need to create the ``Dockerfile``. It will describe the daemon's build process and
specify how to run it. We will use ``python:3.9-buster`` as a base image.
Put next lines into the ``Dockerfile`` file:
.. code-block:: bash
FROM python:3.10-buster
ENV PYTHONUNBUFFERED=1
WORKDIR /code
COPY . /code/
RUN apt-get install openssl \
&& pip install --upgrade pip \
&& pip install -r requirements.txt \
&& rm -rf ~/.cache
CMD ["python", "-m", "monitoringdaemon"]
Third, we need to define the container in the docker-compose configuration.
Put next lines into the ``docker-compose.yml`` file:
.. code-block:: yaml
version: "3.7"
services:
monitor:
build: ./
image: monitoring-daemon
volumes:
- "./:/code"
All is ready. Let's check that the environment is setup properly.
Run in the terminal:
.. code-block:: bash
docker-compose build
The build process may take a couple of minutes. You should see something like this in the end:
.. code-block:: bash
Successfully built 5b4ee5e76e35
Successfully tagged monitoring-daemon:latest
After the build is done run the container:
.. code-block:: bash
docker-compose up
The output should look like:
.. code-block:: bash
Creating network "asyncio-daemon-tutorial_default" with the default driver
Creating asyncio-daemon-tutorial_monitor_1 ... done
Attaching to asyncio-daemon-tutorial_monitor_1
asyncio-daemon-tutorial_monitor_1 exited with code 0
The environment is ready. The application does not do any work and just exits with a code ``0``.
Next step is to configure the logging and configuration file parsing.
Logging and configuration
-------------------------
In this section we will configure the logging and configuration file parsing.
Let's start with the the main part of our application the container. Container will keep all of
the application components and their dependencies.
First two components that we're going to add are the configuration provider and the resource provider
for configuring the logging.
Put next lines into the ``containers.py`` file:
.. code-block:: python
"""Containers module."""
import logging
import sys
from dependency_injector import containers, providers
class Container(containers.DeclarativeContainer):
config = providers.Configuration(yaml_files=["config.yml"])
logging = providers.Resource(
logging.basicConfig,
stream=sys.stdout,
level=config.log.level,
format=config.log.format,
)
The configuration file will keep the logging settings. Put next lines into the ``config.yml`` file:
.. code-block:: yaml
log:
level: "INFO"
format: "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"
Now let's create the function that will run our daemon. It's traditionally called ``main()``.
The ``main()`` function will start the dispatcher, but we will keep it empty for now.
We will create the container instance before calling ``main()`` in ``if __name__ == "__main__"``.
Container instance will parse ``config.yml`` and then we will call the logging configuration provider.
Put next lines into the ``__main__.py`` file:
.. code-block:: python
"""Main module."""
from .containers import Container
def main() -> None:
...
if __name__ == "__main__":
container = Container()
container.init_resources()
main()
.. note::
Container is the first object in the application.
Logging and configuration parsing part is done. In next section we will create the monitoring
checks dispatcher.
Dispatcher
----------
Now let's add the monitoring checks dispatcher.
The dispatcher will control a list of the monitoring tasks. It will execute each task according
to the configured schedule. The ``Monitor`` class is the base class for all the monitors. You can
create different monitors by subclassing it and implementing the ``check()`` method.
.. image:: asyncio-images/classes-01.png
Let's create dispatcher and the monitor base classes.
Create ``dispatcher.py`` and ``monitors.py`` in the ``monitoringdaemon`` package:
.. code-block:: bash
:emphasize-lines: 6-7
./
├── monitoringdaemon/
│ ├── __init__.py
│ ├── __main__.py
│ ├── containers.py
│ ├── dispatcher.py
│ └── monitors.py
├── config.yml
├── docker-compose.yml
├── Dockerfile
└── requirements.txt
Put next into the ``monitors.py``:
.. code-block:: python
"""Monitors module."""
import logging
class Monitor:
def __init__(self, check_every: int) -> None:
self.check_every = check_every
self.logger = logging.getLogger(self.__class__.__name__)
async def check(self) -> None:
raise NotImplementedError()
and next into the ``dispatcher.py``:
.. code-block:: python
"""Dispatcher module."""
import asyncio
import logging
import signal
import time
from typing import List
from .monitors import Monitor
class Dispatcher:
def __init__(self, monitors: List[Monitor]) -> None:
self._monitors = monitors
self._monitor_tasks: List[asyncio.Task] = []
self._logger = logging.getLogger(self.__class__.__name__)
self._stopping = False
def run(self) -> None:
asyncio.run(self.start())
async def start(self) -> None:
self._logger.info("Starting up")
for monitor in self._monitors:
self._monitor_tasks.append(
asyncio.create_task(self._run_monitor(monitor)),
)
asyncio.get_event_loop().add_signal_handler(signal.SIGTERM, self.stop)
asyncio.get_event_loop().add_signal_handler(signal.SIGINT, self.stop)
await asyncio.gather(*self._monitor_tasks, return_exceptions=True)
self.stop()
def stop(self) -> None:
if self._stopping:
return
self._stopping = True
self._logger.info("Shutting down")
for task, monitor in zip(self._monitor_tasks, self._monitors):
task.cancel()
self._monitor_tasks.clear()
self._logger.info("Shutdown finished successfully")
@staticmethod
async def _run_monitor(monitor: Monitor) -> None:
def _until_next(last: float) -> float:
time_took = time.time() - last
return monitor.check_every - time_took
while True:
time_start = time.time()
try:
await monitor.check()
except asyncio.CancelledError:
break
except Exception:
monitor.logger.exception("Error executing monitor check")
await asyncio.sleep(_until_next(last=time_start))
Now we need to add the dispatcher to the container.
Edit ``containers.py``:
.. code-block:: python
:emphasize-lines: 8,22-27
"""Containers module."""
import logging
import sys
from dependency_injector import containers, providers
from . import dispatcher
class Container(containers.DeclarativeContainer):
config = providers.Configuration(yaml_files=["config.yml"])
logging = providers.Resource(
logging.basicConfig,
stream=sys.stdout,
level=config.log.level,
format=config.log.format,
)
dispatcher = providers.Factory(
dispatcher.Dispatcher,
monitors=providers.List(
# TODO: add monitors
),
)
At the last we will inject dispatcher into the ``main()`` function
and call the ``run()`` method. We will use :ref:`wiring` feature.
Edit ``__main__.py``:
.. code-block:: python
:emphasize-lines: 3-5,9-11,17
"""Main module."""
from dependency_injector.wiring import Provide, inject
from .dispatcher import Dispatcher
from .containers import Container
@inject
def main(dispatcher: Dispatcher = Provide[Container.dispatcher]) -> None:
dispatcher.run()
if __name__ == "__main__":
container = Container()
container.init_resources()
container.wire(modules=[__name__])
main()
Finally let's start the daemon to check that all works.
Run in the terminal:
.. code-block:: bash
docker-compose up
The output should look like:
.. code-block:: bash
Starting asyncio-daemon-tutorial_monitor_1 ... done
Attaching to asyncio-daemon-tutorial_monitor_1
monitor_1 | [2020-08-08 16:12:35,772] [INFO] [Dispatcher]: Starting up
monitor_1 | [2020-08-08 16:12:35,774] [INFO] [Dispatcher]: Shutting down
monitor_1 | [2020-08-08 16:12:35,774] [INFO] [Dispatcher]: Shutdown finished successfully
asyncio-daemon-tutorial_monitor_1 exited with code 0
Everything works properly. Dispatcher starts up and exits because there are no monitoring tasks.
By the end of this section we have the application skeleton ready. In next section will will
add first monitoring task.
Example.com monitor
-------------------
In this section we will add a monitoring task that will check the availability of the
`http://example.com <http://example.com>`_.
We will start from the extending of our class model with a new type of the monitoring check, the
``HttpMonitor``.
The ``HttpMonitor`` is a subclass of the ``Monitor``. We will implement the ``check()`` method that
will send the HTTP request to the specified URL. The http request sending will be delegated to
the ``HttpClient``.
.. image:: asyncio-images/classes-02.png
First we need to create the ``HttpClient``.
Create ``http.py`` in the ``monitoringdaemon`` package:
.. code-block:: bash
:emphasize-lines: 7
./
├── monitoringdaemon/
│ ├── __init__.py
│ ├── __main__.py
│ ├── containers.py
│ ├── dispatcher.py
│ ├── http.py
│ └── monitors.py
├── config.yml
├── docker-compose.yml
├── Dockerfile
└── requirements.txt
and put next into it:
.. code-block:: python
"""Http client module."""
from aiohttp import ClientSession, ClientTimeout, ClientResponse
class HttpClient:
async def request(self, method: str, url: str, timeout: int) -> ClientResponse:
async with ClientSession(timeout=ClientTimeout(timeout)) as session:
async with session.request(method, url) as response:
return response
Now we need to add the ``HttpClient`` to the container.
Edit ``containers.py``:
.. code-block:: python
:emphasize-lines: 8,22
"""Containers module."""
import logging
import sys
from dependency_injector import containers, providers
from . import http, dispatcher
class Container(containers.DeclarativeContainer):
config = providers.Configuration(yaml_files=["config.yml"])
logging = providers.Resource(
logging.basicConfig,
stream=sys.stdout,
level=config.log.level,
format=config.log.format,
)
http_client = providers.Factory(http.HttpClient)
dispatcher = providers.Factory(
dispatcher.Dispatcher,
monitors=providers.List(
# TODO: add monitors
),
)
Now we're ready to add the ``HttpMonitor``. We will add it to the ``monitors`` module.
Edit ``monitors.py``:
.. code-block:: python
:emphasize-lines: 4-7,20-56
"""Monitors module."""
import logging
import time
from typing import Dict, Any
from .http import HttpClient
class Monitor:
def __init__(self, check_every: int) -> None:
self.check_every = check_every
self.logger = logging.getLogger(self.__class__.__name__)
async def check(self) -> None:
raise NotImplementedError()
class HttpMonitor(Monitor):
def __init__(
self,
http_client: HttpClient,
options: Dict[str, Any],
) -> None:
self._client = http_client
self._method = options.pop("method")
self._url = options.pop("url")
self._timeout = options.pop("timeout")
super().__init__(check_every=options.pop("check_every"))
async def check(self) -> None:
time_start = time.time()
response = await self._client.request(
method=self._method,
url=self._url,
timeout=self._timeout,
)
time_end = time.time()
time_took = time_end - time_start
self.logger.info(
"Check\n"
" %s %s\n"
" response code: %s\n"
" content length: %s\n"
" request took: %s seconds",
self._method,
self._url,
response.status,
response.content_length,
round(time_took, 3)
)
We have everything ready to add the `http://example.com <http://example.com>`_ monitoring check.
We make two changes in the container:
- Add the factory provider ``example_monitor``.
- Inject the ``example_monitor`` into the dispatcher.
Edit ``containers.py``:
.. code-block:: python
:emphasize-lines: 8,24-28,33
"""Containers module."""
import logging
import sys
from dependency_injector import containers, providers
from . import http, monitors, dispatcher
class Container(containers.DeclarativeContainer):
config = providers.Configuration(yaml_files=["config.yml"])
logging = providers.Resource(
logging.basicConfig,
stream=sys.stdout,
level=config.log.level,
format=config.log.format,
)
http_client = providers.Factory(http.HttpClient)
example_monitor = providers.Factory(
monitors.HttpMonitor,
http_client=http_client,
options=config.monitors.example,
)
dispatcher = providers.Factory(
dispatcher.Dispatcher,
monitors=providers.List(
example_monitor,
),
)
Provider ``example_monitor`` has a dependency on the configuration options. Let's define these
options.
Edit ``config.yml``:
.. code-block:: yaml
:emphasize-lines: 5-11
log:
level: "INFO"
format: "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"
monitors:
example:
method: "GET"
url: "http://example.com"
timeout: 5
check_every: 5
All set. Start the daemon to check that all works.
Run in the terminal:
.. code-block:: bash
docker-compose up
You should see:
.. code-block:: bash
Starting asyncio-daemon-tutorial_monitor_1 ... done
Attaching to asyncio-daemon-tutorial_monitor_1
monitor_1 | [2020-08-08 17:06:41,965] [INFO] [Dispatcher]: Starting up
monitor_1 | [2020-08-08 17:06:42,033] [INFO] [HttpMonitor]: Check
monitor_1 | GET http://example.com
monitor_1 | response code: 200
monitor_1 | content length: 648
monitor_1 | request took: 0.067 seconds
monitor_1 | [2020-08-08 17:06:47,040] [INFO] [HttpMonitor]: Check
monitor_1 | GET http://example.com
monitor_1 | response code: 200
monitor_1 | content length: 648
monitor_1 | request took: 0.073 seconds
Our daemon can monitor `http://example.com <http://example.com>`_ availability.
Let's add a monitor for the `https://httpbin.org <https://httpbin.org>`_.
Httpbin.org monitor
-------------------
Adding of a monitor for the `https://httpbin.org <https://httpbin.org>`_ will be much
easier because we have all the components ready. We just need to create a new provider
in the container and update the configuration.
Edit ``containers.py``:
.. code-block:: python
:emphasize-lines: 30-34,40
"""Containers module."""
import logging
import sys
from dependency_injector import containers, providers
from . import http, monitors, dispatcher
class Container(containers.DeclarativeContainer):
config = providers.Configuration(yaml_files=["config.yml"])
logging = providers.Resource(
logging.basicConfig,
stream=sys.stdout,
level=config.log.level,
format=config.log.format,
)
http_client = providers.Factory(http.HttpClient)
example_monitor = providers.Factory(
monitors.HttpMonitor,
http_client=http_client,
options=config.monitors.example,
)
httpbin_monitor = providers.Factory(
monitors.HttpMonitor,
http_client=http_client,
options=config.monitors.httpbin,
)
dispatcher = providers.Factory(
dispatcher.Dispatcher,
monitors=providers.List(
example_monitor,
httpbin_monitor,
),
)
Edit ``config.yml``:
.. code-block:: yaml
:emphasize-lines: 13-17
log:
level: "INFO"
format: "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s"
monitors:
example:
method: "GET"
url: "http://example.com"
timeout: 5
check_every: 5
httpbin:
method: "GET"
url: "https://httpbin.org/get"
timeout: 5
check_every: 5
Let's start the daemon and check the logs.
Run in the terminal:
.. code-block:: bash
docker-compose up
You should see:
.. code-block:: bash
Starting asyncio-daemon-tutorial_monitor_1 ... done
Attaching to asyncio-daemon-tutorial_monitor_1
monitor_1 | [2020-08-08 18:09:08,540] [INFO] [Dispatcher]: Starting up
monitor_1 | [2020-08-08 18:09:08,618] [INFO] [HttpMonitor]: Check
monitor_1 | GET http://example.com
monitor_1 | response code: 200
monitor_1 | content length: 648
monitor_1 | request took: 0.077 seconds
monitor_1 | [2020-08-08 18:09:08,722] [INFO] [HttpMonitor]: Check
monitor_1 | GET https://httpbin.org/get
monitor_1 | response code: 200
monitor_1 | content length: 310
monitor_1 | request took: 0.18 seconds
monitor_1 | [2020-08-08 18:09:13,619] [INFO] [HttpMonitor]: Check
monitor_1 | GET http://example.com
monitor_1 | response code: 200
monitor_1 | content length: 648
monitor_1 | request took: 0.066 seconds
monitor_1 | [2020-08-08 18:09:13,681] [INFO] [HttpMonitor]: Check
monitor_1 | GET https://httpbin.org/get
monitor_1 | response code: 200
monitor_1 | content length: 310
monitor_1 | request took: 0.126 seconds
The functional part is done. Daemon monitors `http://example.com <http://example.com>`_ and
`https://httpbin.org <https://httpbin.org>`_.
In next section we will add some tests.
Tests
-----
In this section we will add some tests.
We will use `pytest <https://docs.pytest.org/en/stable/>`_ and
`coverage <https://coverage.readthedocs.io/>`_.
Create ``tests.py`` in the ``monitoringdaemon`` package:
.. code-block:: bash
:emphasize-lines: 9
./
├── monitoringdaemon/
│ ├── __init__.py
│ ├── __main__.py
│ ├── containers.py
│ ├── dispatcher.py
│ ├── http.py
│ ├── monitors.py
│ └── tests.py
├── config.yml
├── docker-compose.yml
├── Dockerfile
└── requirements.txt
and put next into it:
.. code-block:: python
:emphasize-lines: 54,70-73
"""Tests module."""
import asyncio
import dataclasses
from unittest import mock
import pytest
from .containers import Container
@dataclasses.dataclass
class RequestStub:
status: int
content_length: int
@pytest.fixture
def container():
return Container(
config={
"log": {
"level": "INFO",
"formant": "[%(asctime)s] [%(levelname)s] [%(name)s]: %(message)s",
},
"monitors": {
"example": {
"method": "GET",
"url": "http://fake-example.com",
"timeout": 1,
"check_every": 1,
},
"httpbin": {
"method": "GET",
"url": "https://fake-httpbin.org/get",
"timeout": 1,
"check_every": 1,
},
},
}
)
@pytest.mark.asyncio
async def test_example_monitor(container, caplog):
caplog.set_level("INFO")
http_client_mock = mock.AsyncMock()
http_client_mock.request.return_value = RequestStub(
status=200,
content_length=635,
)
with container.http_client.override(http_client_mock):
example_monitor = container.example_monitor()
await example_monitor.check()
assert "http://fake-example.com" in caplog.text
assert "response code: 200" in caplog.text
assert "content length: 635" in caplog.text
@pytest.mark.asyncio
async def test_dispatcher(container, caplog, event_loop):
caplog.set_level("INFO")
example_monitor_mock = mock.AsyncMock()
httpbin_monitor_mock = mock.AsyncMock()
with container.override_providers(
example_monitor=example_monitor_mock,
httpbin_monitor=httpbin_monitor_mock,
):
dispatcher = container.dispatcher()
event_loop.create_task(dispatcher.start())
await asyncio.sleep(0.1)
dispatcher.stop()
assert example_monitor_mock.check.called
assert httpbin_monitor_mock.check.called
Run in the terminal:
.. code-block:: bash
docker-compose run --rm monitor py.test monitoringdaemon/tests.py --cov=monitoringdaemon
You should see:
.. code-block:: bash
platform linux -- Python 3.10.0, pytest-6.2.5, py-1.10.0, pluggy-1.0.0
rootdir: /code
plugins: asyncio-0.16.0, cov-3.0.0
collected 2 items
monitoringdaemon/tests.py .. [100%]
---------- coverage: platform linux, python 3.10.0-final-0 -----------
Name Stmts Miss Cover
----------------------------------------------------
monitoringdaemon/__init__.py 0 0 100%
monitoringdaemon/__main__.py 11 11 0%
monitoringdaemon/containers.py 11 0 100%
monitoringdaemon/dispatcher.py 45 5 89%
monitoringdaemon/http.py 6 3 50%
monitoringdaemon/monitors.py 23 1 96%
monitoringdaemon/tests.py 35 0 100%
----------------------------------------------------
TOTAL 131 20 85%
.. note::
Take a look at the highlights in the ``tests.py``.
In the ``test_example_monitor`` it emphasizes the overriding of the ``HttpClient``. The real
HTTP calls are mocked.
In the ``test_dispatcher`` we override both monitors with the mocks.
Conclusion
----------
In this tutorial we've built an ``asyncio`` monitoring daemon following the dependency
injection principle.
We've used the ``Dependency Injector`` as a dependency injection framework.
With a help of :ref:`containers` and :ref:`providers` we have defined how to assemble application components.
``List`` provider helped to inject a list of monitors into dispatcher.
:ref:`configuration-provider` helped to deal with reading YAML file.
We used :ref:`wiring` feature to inject dispatcher into the ``main()`` function.
:ref:`provider-overriding` feature helped in testing.
We kept all the dependencies injected explicitly. This will help when you need to add or
change something in future.
You can find complete project on the
`Github <https://github.com/ets-labs/python-dependency-injector/tree/master/examples/miniapps/asyncio-daemon>`_.
What's next?
- Look at the other :ref:`tutorials`
- Know more about the :ref:`providers`
- Go to the :ref:`contents`
.. include:: ../sponsor.rst
.. disqus::