Frasco + Inyector de dependencia - guía de inyección de dependencia

Hola,



soy el creador de Dependency Injector . Este es un marco de inyección de dependencia para Python.



En este tutorial, quiero mostrarle cómo utilizar el inyector de dependencia para desarrollar aplicaciones Flask.



El manual consta de las siguientes partes:



  1. ¿Qué vamos a construir?
  2. Prepara el medio ambiente
  3. Estructura del proyecto
  4. ¡Hola Mundo!
  5. Incluyendo estilos
  6. Conectando Github
  7. Servicio de búsqueda
  8. Conectar búsqueda
  9. Un poco de refactorización
  10. Agregar pruebas
  11. Conclusión


El proyecto completo se puede encontrar en Github .



Para empezar debes tener:



  • Python 3.5+
  • Ambiente virtual


Y es deseable tener:



  • Habilidades de desarrollo inicial con Flask
  • Comprender el principio de inyección de dependencia


¿Qué vamos a construir?



Crearemos una aplicación que te ayudará a buscar repositorios en Github. Llamémoslo Github Navigator.



¿Cómo funciona Github Navigator?



  • El usuario abre una página web donde se le solicita que ingrese una consulta de búsqueda.
  • El usuario ingresa una consulta y presiona Enter.
  • Github Navigator busca repositorios coincidentes en Github.
  • Cuando finaliza la búsqueda, Github Navigator muestra al usuario una página web con resultados.
  • La página de resultados muestra todos los repositorios encontrados y la consulta de búsqueda.
  • Para cada repositorio, el usuario ve:

    • nombre del repositorio
    • propietario del repositorio
    • el último compromiso con el repositorio
  • El usuario puede hacer clic en cualquiera de los elementos para abrir su página en Github.






Prepara el medio ambiente



En primer lugar, necesitamos crear una carpeta de proyecto y un entorno virtual:



mkdir ghnav-flask-tutorial
cd ghnav-flask-tutorial
python3 -m venv venv


Ahora activemos el entorno virtual:



. venv/bin/activate


El entorno está listo, ahora comencemos con la estructura del proyecto.



Estructura del proyecto



Creemos la siguiente estructura en la carpeta actual. Deje todos los archivos vacíos por ahora. Esto aún no es crítico.



Estructura inicial:



./
├── githubnavigator/
│   ├── __init__.py
│   ├── application.py
│   ├── containers.py
│   └── views.py
├── venv/
└── requirements.txt


Es hora de instalar Flask and Dependency Injector.



Agreguemos las siguientes líneas al archivo requirements.txt:



dependency-injector
flask


Ahora instalémoslos:



pip install -r requirements.txt


Y compruebe que la instalación se haya realizado correctamente:



python -c "import dependency_injector; print(dependency_injector.__version__)"
python -c "import flask; print(flask.__version__)"


Verás algo como:



(venv) $ python -c "import dependency_injector; print(dependency_injector.__version__)"
3.22.0
(venv) $ python -c "import flask; print(flask.__version__)"
1.1.2


¡Hola Mundo!



Creemos una aplicación mínima de hola mundo.



Agreguemos las siguientes líneas al archivo views.py:



"""Views module."""


def index():
    return 'Hello, World!'


Ahora agreguemos un contenedor de dependencias (además, solo un contenedor). El contenedor contendrá todos los componentes de la aplicación. Agreguemos los dos primeros componentes. Esta es una aplicación y vista de Flask index.



Agreguemos lo siguiente al archivo containers.py:



"""Application containers module."""

from dependency_injector import containers
from dependency_injector.ext import flask
from flask import Flask

from . import views


class ApplicationContainer(containers.DeclarativeContainer):
    """Application container."""

    app = flask.Application(Flask, __name__)

    index_view = flask.View(views.index)


Ahora tenemos que crear una fábrica de aplicaciones Flask. Suele llamarse create_app(). Creará un contenedor. El contenedor se utilizará para crear la aplicación Flask. El paso final es configurar el enrutamiento: asignaremos una vista index_viewdesde el contenedor para manejar las solicitudes a la raíz "/" de nuestra aplicación.



Editemos application.py:



"""Application module."""

from .containers import ApplicationContainer


def create_app():
    """Create and return Flask application."""
    container = ApplicationContainer()

    app = container.app()
    app.container = container

    app.add_url_rule('/', view_func=container.index_view.as_view())

    return app


El contenedor es el primer objeto de la aplicación. Se usa para obtener todos los demás objetos.


Nuestra aplicación ya está lista para decir "¡Hola, mundo!"



Ejecutar en una terminal:



export FLASK_APP=githubnavigator.application
export FLASK_ENV=development
flask run


La salida debería verse así:



* Serving Flask app "githubnavigator.application" (lazy loading)
* Environment: development
* Debug mode: on
* Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)
* Restarting with fsevents reloader
* Debugger is active!
* Debugger PIN: 473-587-859


Abra su navegador y vaya a http://127.0.0.1:5000/ .



Verá "¡Hola, mundo!"



Excelente. Nuestra aplicación mínima se inicia y se ejecuta correctamente.



Hagámoslo un poco más bonito.



Incluyendo estilos



Usaremos Bootstrap 4 . Usemos la extensión Bootstrap-Flask para esto . Nos ayudará a agregar todos los archivos necesarios en unos pocos clics.



Agregar bootstrap-flaska requirements.txt:



dependency-injector
flask
bootstrap-flask


y ejecutar en la terminal:



pip install --upgrade -r requirements.txt


Ahora agreguemos la extensión bootstrap-flaskal contenedor.



Editar containers.py:



"""Application containers module."""

from dependency_injector import containers
from dependency_injector.ext import flask
from flask import Flask
from flask_bootstrap import Bootstrap

from . import views


class ApplicationContainer(containers.DeclarativeContainer):
    """Application container."""

    app = flask.Application(Flask, __name__)

    bootstrap = flask.Extension(Bootstrap)

    index_view = flask.View(views.index)


Inicialicemos la extensión bootstrap-flask. Tendremos que cambiar create_app().



Editar application.py:



"""Application module."""

from .containers import ApplicationContainer


def create_app():
    """Create and return Flask application."""
    container = ApplicationContainer()

    app = container.app()
    app.container = container

    bootstrap = container.bootstrap()
    bootstrap.init_app(app)

    app.add_url_rule('/', view_func=container.index_view.as_view())

    return app


Ahora necesitamos agregar plantillas. Para hacer esto, necesitamos agregar una carpeta templates/al paquete githubnavigator. Agregue dos archivos dentro de la carpeta de plantillas:



  • base.html - plantilla básica
  • index.html - plantilla de página principal


Cree una carpeta templatesy dos archivos vacíos dentro base.htmly index.html:



./
├── githubnavigator/
│   ├── templates/
│   │   ├── base.html
│   │   └── index.html
│   ├── __init__.py
│   ├── application.py
│   ├── containers.py
│   └── views.py
├── venv/
└── requirements.txt


Ahora completemos la plantilla básica.



Agreguemos las siguientes líneas al archivo base.html:



<!doctype html>
<html lang="en">
    <head>
        {% block head %}
        <!-- Required meta tags -->
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

        {% block styles %}
            <!-- Bootstrap CSS -->
            {{ bootstrap.load_css() }}
        {% endblock %}

        <title>{% block title %}{% endblock %}</title>
        {% endblock %}
    </head>
    <body>
        <!-- Your page content -->
        {% block content %}{% endblock %}

        {% block scripts %}
            <!-- Optional JavaScript -->
            {{ bootstrap.load_js() }}
        {% endblock %}
    </body>
</html>


Ahora completemos la plantilla de la página maestra.



Agreguemos las siguientes líneas al archivo index.html:



{% extends "base.html" %}

{% block title %}Github Navigator{% endblock %}

{% block content %}
<div class="container">
    <h1 class="mb-4">Github Navigator</h1>

    <form>
        <div class="form-group form-row">
            <div class="col-10">
                <label for="search_query" class="col-form-label">
                    Search for:
                </label>
                <input class="form-control" type="text" id="search_query"
                       placeholder="Type something to search on the GitHub"
                       name="query"
                       value="{{ query if query }}">
            </div>
            <div class="col">
                <label for="search_limit" class="col-form-label">
                    Limit:
                </label>
                <select class="form-control" id="search_limit" name="limit">
                    {% for value in [5, 10, 20] %}
                    <option {% if value == limit %}selected{% endif %}>
                        {{ value }}
                    </option>
                    {% endfor %}
                </select>
            </div>
        </div>
    </form>

    <p><small>Results found: {{ repositories|length }}</small></p>

    <table class="table table-striped">
        <thead>
            <tr>
                <th>#</th>
                <th>Repository</th>
                <th class="text-nowrap">Repository owner</th>
                <th class="text-nowrap">Last commit</th>
            </tr>
        </thead>
        <tbody>
        {% for repository in repositories %} {{n}}
            <tr>
              <th>{{ loop.index }}</th>
              <td><a href="{{ repository.url }}">
                  {{ repository.name }}</a>
              </td>
              <td><a href="{{ repository.owner.url }}">
                  <img src="{{ repository.owner.avatar_url }}"
                       alt="avatar" height="24" width="24"/></a>
                  <a href="{{ repository.owner.url }}">
                      {{ repository.owner.login }}</a>
              </td>
              <td><a href="{{ repository.latest_commit.url }}">
                  {{ repository.latest_commit.sha }}</a>
                  {{ repository.latest_commit.message }}
                  {{ repository.latest_commit.author_name }}
              </td>
            </tr>
        {% endfor %}
        </tbody>
    </table>
</div>

{% endblock %}


Genial, casi terminado. El último paso es cambiar la vista indexpara usar la plantilla index.html.



Editemos views.py:



"""Views module."""

from flask import request, render_template


def index():
    query = request.args.get('query', 'Dependency Injector')
    limit = request.args.get('limit', 10, int)

    repositories = []

    return render_template(
        'index.html',
        query=query,
        limit=limit,
        repositories=repositories,
    )


Hecho.



Asegúrese de que la aplicación se esté ejecutando o ejecute flask runy abra http://127.0.0.1:5000/ .



Debería ver:







Conectando Github



En esta sección, integraremos nuestra aplicación con la API de Github.

Usaremos la biblioteca PyGithub .



Vamos a agregarlo a requirements.txt:



dependency-injector
flask
bootstrap-flask
pygithub


y ejecutar en la terminal:



pip install --upgrade -r requirements.txt


Ahora necesitamos agregar el cliente API de Github al contenedor. Para hacer esto, necesitaremos utilizar dos nuevos proveedores del módulo dependency_injector.providers:



  • El proveedor Factorycreará el cliente Github.
  • El proveedor Configurationpasará el token de API y el tiempo de espera de Github al cliente.


Vamos a hacerlo.



Editemos containers.py:



"""Application containers module."""

from dependency_injector import containers, providers
from dependency_injector.ext import flask
from flask import Flask
from flask_bootstrap import Bootstrap
from github import Github

from . import views


class ApplicationContainer(containers.DeclarativeContainer):
    """Application container."""

    app = flask.Application(Flask, __name__)

    bootstrap = flask.Extension(Bootstrap)

    config = providers.Configuration()

    github_client = providers.Factory(
        Github,
        login_or_token=config.github.auth_token,
        timeout=config.github.request_timeout,
    )

    index_view = flask.View(views.index)


Usamos los parámetros de configuración antes de establecer sus valores. Este es el principio por el que trabaja el proveedor Configuration.



Primero usamos, luego establecemos los valores.



Ahora agreguemos el archivo de configuración.

Usaremos YAML.



Cree un archivo vacío config.ymlen la raíz del proyecto:



./
├── githubnavigator/
│   ├── templates/
│   │   ├── base.html
│   │   └── index.html
│   ├── __init__.py
│   ├── application.py
│   ├── containers.py
│   └── views.py
├── venv/
├── config.yml
└── requirements.txt


Y rellénelo con las siguientes líneas:



github:
  request_timeout: 10


Para trabajar con el archivo de configuración, usaremos la biblioteca PyYAML . Agreguémoslo al archivo con dependencias.



Editar requirements.txt:



dependency-injector
flask
bootstrap-flask
pygithub
pyyaml


e instala la dependencia:



pip install --upgrade -r requirements.txt


Usaremos una variable de entorno para pasar el token de API GITHUB_TOKEN.



Ahora necesitamos editar create_app()para hacer 2 acciones cuando se inicia la aplicación:



  • Cargar configuración desde config.yml
  • Cargar token de API desde la variable de entorno GITHUB_TOKEN


Editar application.py:



"""Application module."""

from .containers import ApplicationContainer


def create_app():
    """Create and return Flask application."""
    container = ApplicationContainer()
    container.config.from_yaml('config.yml')
    container.config.github.auth_token.from_env('GITHUB_TOKEN')

    app = container.app()
    app.container = container

    bootstrap = container.bootstrap()
    bootstrap.init_app(app)

    app.add_url_rule('/', view_func=container.index_view.as_view())

    return app


Ahora necesitamos crear un token de API.



Para esto necesitas:



  • Siga este tutorial en Github
  • Establezca el token en la variable de entorno:



    export GITHUB_TOKEN=<your token>


Este elemento se puede omitir temporalmente.



La aplicación se ejecutará sin un token, pero con un ancho de banda limitado. Límite de clientes no autenticados: 60 solicitudes por hora. El token es necesario para aumentar esta cuota a 5000 por hora.


Hecho.



La instalación de la API de Github del cliente está completa.



Servicio de búsqueda



Es hora de agregar un servicio de búsqueda SearchService. Él:



  • Buscar en Github
  • Obtenga datos adicionales sobre confirmaciones
  • Convertir formato de resultado


SearchServiceutilizará el cliente API de Github.



Cree un archivo vacío services.pyen el paquete githubnavigator:



./
├── githubnavigator/
│   ├── templates/
│   │   ├── base.html
│   │   └── index.html
│   ├── __init__.py
│   ├── application.py
│   ├── containers.py
│   ├── services.py
│   └── views.py
├── venv/
├── config.yml
└── requirements.txt


y agregue las siguientes líneas:



"""Services module."""

from github import Github
from github.Repository import Repository
from github.Commit import Commit


class SearchService:
    """Search service performs search on Github."""

    def __init__(self, github_client: Github):
        self._github_client = github_client

    def search_repositories(self, query, limit):
        """Search for repositories and return formatted data."""
        repositories = self._github_client.search_repositories(
            query=query,
            **{'in': 'name'},
        )
        return [
            self._format_repo(repository)
            for repository in repositories[:limit]
        ]

    def _format_repo(self, repository: Repository):
        commits = repository.get_commits()
        return {
            'url': repository.html_url,
            'name': repository.name,
            'owner': {
                'login': repository.owner.login,
                'url': repository.owner.html_url,
                'avatar_url': repository.owner.avatar_url,
            },
            'latest_commit': self._format_commit(commits[0]) if commits else {},
        }

    def _format_commit(self, commit: Commit):
        return {
            'sha': commit.sha,
            'url': commit.html_url,
            'message': commit.commit.message,
            'author_name': commit.commit.author.name,
        }


Ahora agreguemos SearchServiceal contenedor.



Editar containers.py:



"""Application containers module."""

from dependency_injector import containers, providers
from dependency_injector.ext import flask
from flask import Flask
from flask_bootstrap import Bootstrap
from github import Github

from . import services, views


class ApplicationContainer(containers.DeclarativeContainer):
    """Application container."""

    app = flask.Application(Flask, __name__)

    bootstrap = flask.Extension(Bootstrap)

    config = providers.Configuration()

    github_client = providers.Factory(
        Github,
        login_or_token=config.github.auth_token,
        timeout=config.github.request_timeout,
    )

    search_service = providers.Factory(
        services.SearchService,
        github_client=github_client,
    )

    index_view = flask.View(views.index)


Conectar búsqueda



Ahora estamos listos para que la búsqueda funcione. Vamos uso SearchServiceen la indexvista.



Editar views.py:



"""Views module."""

from flask import request, render_template

from .services import SearchService


def index(search_service: SearchService):
    query = request.args.get('query', 'Dependency Injector')
    limit = request.args.get('limit', 10, int)

    repositories = search_service.search_repositories(query, limit)

    return render_template(
        'index.html',
        query=query,
        limit=limit,
        repositories=repositories,
    )


Ahora cambiemos el contenedor para pasar la dependencia SearchServicea la vista indexcuando se la llame.



Editar containers.py:



"""Application containers module."""

from dependency_injector import containers, providers
from dependency_injector.ext import flask
from flask import Flask
from flask_bootstrap import Bootstrap
from github import Github

from . import services, views


class ApplicationContainer(containers.DeclarativeContainer):
    """Application container."""

    app = flask.Application(Flask, __name__)

    bootstrap = flask.Extension(Bootstrap)

    config = providers.Configuration()

    github_client = providers.Factory(
        Github,
        login_or_token=config.github.auth_token,
        timeout=config.github.request_timeout,
    )

    search_service = providers.Factory(
        services.SearchService,
        github_client=github_client,
    )

    index_view = flask.View(
        views.index,
        search_service=search_service,
    )


Asegúrese de que la aplicación se esté ejecutando o se esté ejecutando flask runy abra http://127.0.0.1:5000/ .



Ya verás:







Un poco de refactorización



Nuestra vista indexcontiene dos valores codificados:



  • Término de búsqueda predeterminado
  • Límite para el número de resultados


Hagamos una pequeña refactorización. Transferiremos estos valores a la configuración.



Editar views.py:



"""Views module."""

from flask import request, render_template

from .services import SearchService


def index(
        search_service: SearchService,
        default_query: str,
        default_limit: int,
):
    query = request.args.get('query', default_query)
    limit = request.args.get('limit', default_limit, int)

    repositories = search_service.search_repositories(query, limit)

    return render_template(
        'index.html',
        query=query,
        limit=limit,
        repositories=repositories,
    )


Ahora necesitamos que estos valores se pasen en la llamada. Actualicemos el contenedor.



Editar containers.py:



"""Application containers module."""

from dependency_injector import containers, providers
from dependency_injector.ext import flask
from flask import Flask
from flask_bootstrap import Bootstrap
from github import Github

from . import services, views


class ApplicationContainer(containers.DeclarativeContainer):
    """Application container."""

    app = flask.Application(Flask, __name__)

    bootstrap = flask.Extension(Bootstrap)

    config = providers.Configuration()

    github_client = providers.Factory(
        Github,
        login_or_token=config.github.auth_token,
        timeout=config.github.request_timeout,
    )

    search_service = providers.Factory(
        services.SearchService,
        github_client=github_client,
    )

    index_view = flask.View(
        views.index,
        search_service=search_service,
        default_query=config.search.default_query,
        default_limit=config.search.default_limit,
    )


Ahora actualice el archivo de configuración.



Editar config.yml:



github:
  request_timeout: 10
search:
  default_query: "Dependency Injector"
  default_limit: 10


Hecho.



La refactorización está completa. Mu hizo el código más limpio.



Agregar pruebas



Sería bueno agregar algunas pruebas. Vamos a hacerlo.



Usaremos Pytest y Cobertura .



Editar requirements.txt:



dependency-injector
flask
bootstrap-flask
pygithub
pyyaml
pytest-flask
pytest-cov


e instalar nuevos paquetes:



pip install -r requirements.txt


Cree un archivo vacío tests.pyen el paquete githubnavigator:



./
├── githubnavigator/
│   ├── templates/
│   │   ├── base.html
│   │   └── index.html
│   ├── __init__.py
│   ├── application.py
│   ├── containers.py
│   ├── services.py
│   ├── tests.py
│   └── views.py
├── venv/
├── config.yml
└── requirements.txt


y agregue las siguientes líneas:



"""Tests module."""

from unittest import mock

import pytest
from github import Github
from flask import url_for

from .application import create_app


@pytest.fixture
def app():
    return create_app()


def test_index(client, app):
    github_client_mock = mock.Mock(spec=Github)
    github_client_mock.search_repositories.return_value = [
        mock.Mock(
            html_url='repo1-url',
            name='repo1-name',
            owner=mock.Mock(
                login='owner1-login',
                html_url='owner1-url',
                avatar_url='owner1-avatar-url',
            ),
            get_commits=mock.Mock(return_value=[mock.Mock()]),
        ),
        mock.Mock(
            html_url='repo2-url',
            name='repo2-name',
            owner=mock.Mock(
                login='owner2-login',
                html_url='owner2-url',
                avatar_url='owner2-avatar-url',
            ),
            get_commits=mock.Mock(return_value=[mock.Mock()]),
        ),
    ]

    with app.container.github_client.override(github_client_mock):
        response = client.get(url_for('index'))

    assert response.status_code == 200
    assert b'Results found: 2' in response.data

    assert b'repo1-url' in response.data
    assert b'repo1-name' in response.data
    assert b'owner1-login' in response.data
    assert b'owner1-url' in response.data
    assert b'owner1-avatar-url' in response.data

    assert b'repo2-url' in response.data
    assert b'repo2-name' in response.data
    assert b'owner2-login' in response.data
    assert b'owner2-url' in response.data
    assert b'owner2-avatar-url' in response.data


def test_index_no_results(client, app):
    github_client_mock = mock.Mock(spec=Github)
    github_client_mock.search_repositories.return_value = []

    with app.container.github_client.override(github_client_mock):
        response = client.get(url_for('index'))

    assert response.status_code == 200
    assert b'Results found: 0' in response.data


Ahora comencemos a probar y verificar la cobertura:



py.test githubnavigator/tests.py --cov=githubnavigator


Ya verás:



platform darwin -- Python 3.8.3, pytest-5.4.3, py-1.9.0, pluggy-0.13.1
plugins: flask-1.0.0, cov-2.10.0
collected 2 items

githubnavigator/tests.py ..                                     [100%]

---------- coverage: platform darwin, python 3.8.3-final-0 -----------
Name                             Stmts   Miss  Cover
----------------------------------------------------
githubnavigator/__init__.py          0      0   100%
githubnavigator/application.py      11      0   100%
githubnavigator/containers.py       13      0   100%
githubnavigator/services.py         14      0   100%
githubnavigator/tests.py            32      0   100%
githubnavigator/views.py             7      0   100%
----------------------------------------------------
TOTAL                               77      0   100%


Observe cómo reemplazamos github_clientcon simulacro usando el método .override(). De esta forma, puede anular el valor de retorno de cualquier proveedor.



Conclusión



Creamos nuestra aplicación Flask usando inyección de dependencia. Usamos Dependency Injector como un marco de inyección de dependencias.



La parte principal de nuestra aplicación es el contenedor. Contiene todos los componentes de la aplicación y sus dependencias en un solo lugar. Esto proporciona control sobre la estructura de la aplicación. Es fácil de entender y cambiar:



"""Application containers module."""

from dependency_injector import containers, providers
from dependency_injector.ext import flask
from flask import Flask
from flask_bootstrap import Bootstrap
from github import Github

from . import services, views


class ApplicationContainer(containers.DeclarativeContainer):
    """Application container."""

    app = flask.Application(Flask, __name__)

    bootstrap = flask.Extension(Bootstrap)

    config = providers.Configuration()

    github_client = providers.Factory(
        Github,
        login_or_token=config.github.auth_token,
        timeout=config.github.request_timeout,
    )

    search_service = providers.Factory(
        services.SearchService,
        github_client=github_client,
    )

    index_view = flask.View(
        views.index,
        search_service=search_service,
        default_query=config.search.default_query,
        default_limit=config.search.default_limit,
    )




Un contenedor como mapa de su aplicación. Siempre sabes qué depende de qué.



¿Que sigue?






All Articles