https://habr.com/ru/post/512242/- Python
- Проектирование и рефакторинг
- Flask
Привет,
Я создатель
Dependency Injector. Это dependency injection фреймворк для Python.
В этом руководстве хочу показать как применять Dependency Injector для разработки Flask приложений.
Руководство состоит из таких частей:
- Что мы будем строить?
- Подготовим окружение
- Структура проекта
- Hello world!
- Подключаем стили
- Подключаем Github
- Сервис поиска
- Подключаем поиск
- Немного рефакторинга
- Добавляем тесты
- Заключение
Завершенный проект можно найти на
Github.
Для старта необходимо иметь:
- Python 3.5+
- Virtual environment
И желательно иметь:
- Начальные навыки разработки с помощью Flask
- Общее представление о принципе dependency injection
Что мы будем строить?
Мы будем строить приложение, которое помогает искать репозитории на Github. Назовем его Github Navigator.
Как работает Github Navigator?
- Пользователь открывает веб-страницу где ему предлагают ввести поисковый запрос.
- Пользователь вводит запрос и нажимает Enter.
- Github Navigator ищет подходящие репозитории на Github.
- По окончанию поиска Github Navigator показывает пользователю веб-страницу с результатами.
- Страница результатов показывает все найденные репозитории и поисковый запрос.
- Для каждого репозитория пользователь видит:
- имя репозитория
- владельца репозитория
- последний коммит в репозиторий
- Пользователь может нажать на любой из элементов чтобы открыть его страницу на Github.
Подготовим окружение
В первую очередь нам нужно создать папку проекта и virtual environment:
mkdir ghnav-flask-tutorial
cd ghnav-flask-tutorial
python3 -m venv venv
Теперь давайте активируем virtual environment:
. venv/bin/activate
Окружение готово, теперь займемся структурой проекта.
Структура проекта
Создадим в текущей папке следующую структуру. Все файлы пока оставляем пустыми. Это пока не критично.
Начальная структура:
./
├── githubnavigator/
│ ├── __init__.py
│ ├── application.py
│ ├── containers.py
│ └── views.py
├── venv/
└── requirements.txt
Пришло время установить Flask и Dependency Injector.
Добавим следующие строки в файл
requirements.txt
:
dependency-injector
flask
Теперь давайте их установим:
pip install -r requirements.txt
И проверим что установка прошла успешно:
python -c "import dependency_injector; print(dependency_injector.__version__)"
python -c "import flask; print(flask.__version__)"
Вы увидите что-то вроде:
(venv) $ python -c "import dependency_injector; print(dependency_injector.__version__)"
3.22.0
(venv) $ python -c "import flask; print(flask.__version__)"
1.1.2
Hello world!
Давайте создадим минимальное hello world приложение.
Добавим следующие строки в файл
views.py
:
"""Views module."""
def index():
return 'Hello, World!'
Теперь добавим контейнер зависимостей (дальше просто контейнер). Контейнер будет содержать все компоненты приложения. Добавим первые два компонента. Это Flask приложение и представление
index
.
Добавим следующее в файл
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)
Теперь нам нужно создать фабрику Flask приложения. Ее обычно называют
create_app()
. Она будет создавать контейнер. Контейнер будет использован для создания Flask приложения. Последним шагом настроим маршрутизацию — мы назначим представление
index_view
из контейнера обрабатывать запросы к корню "/" нашего приложения.
Отредактируем
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
Контейнер — первый объект в приложении. Он используется для получения всех остальных объектов.
Теперь наше приложение готово сказать «Hello, World!».
Выполните в терминале:
export FLASK_APP=githubnavigator.application
export FLASK_ENV=development
flask run
Вывод должен выглядеть приблизительно так:
* 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
Откройте браузер и зайдите на
http://127.0.0.1:5000/.
Вы увидите «Hello, World!».
Отлично. Наше минимальное приложение успешно стартует и работает.
Давайте сделаем его немного красивее.
Подключаем стили
Мы будем использовать
Bootstrap 4. Используем для этого расширение
Bootstrap-Flask. Оно поможет нам добавить все нужные файлы в несколько кликов.
Добавим
bootstrap-flask
в
requirements.txt
:
dependency-injector
flask
bootstrap-flask
и выполним в терминале:
pip install --upgrade -r requirements.txt
Теперь добавим расширение
bootstrap-flask
в контейнер.
Отредактируйте
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)
Давайте инициализируем расширение
bootstrap-flask
. Нам нужно будет изменить
create_app()
.
Отредактируйте
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
Теперь нужно добавить шаблоны. Для этого нам понадобится добавить папку
templates/
в пакет
githubnavigator
. Внутри папки с шаблонами добавим два файла:
base.html
— базовый шаблон
index.html
— шаблон основной страницы
Создаем папку
templates
и два пустых файла внутри
base.html
и
index.html
:
./
├── githubnavigator/
│ ├── templates/
│ │ ├── base.html
│ │ └── index.html
│ ├── __init__.py
│ ├── application.py
│ ├── containers.py
│ └── views.py
├── venv/
└── requirements.txt
Теперь давайте наполним базовый шаблон.
Добавим следующие строки в файл
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>
Теперь наполним шаблон основной страницы.
Добавим следующие строки в файл
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 %}
Отлично, почти готово. Последним шагом изменим представление
index
чтобы оно использовало шаблон
index.html
.
Отредактируем
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,
)
Готово.
Убедитесь что приложение работает или выполните
flask run
и откройте
http://127.0.0.1:5000/.
Вы должны увидите:
Подключаем Github
В этом разделе интегрируем наше приложение с Github API.
Мы будем использовать библиотеку
PyGithub.
Добавим её в
requirements.txt
:
dependency-injector
flask
bootstrap-flask
pygithub
и выполним в терминале:
pip install --upgrade -r requirements.txt
Теперь нам нужно добавить Github API клиент в контейнер. Для этого нам нужно будет воспользоваться двумя новыми провайдерами из модуля
dependency_injector.providers
:
- Провайдер
Factory
будет создавать Github клиент.
- Провайдер
Configuration
будет передавать API токен и таймаут Github клиенту.
Сделаем это.
Отредактируем
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)
Мы использовали параметры конфигурации перед тем как задали их значения. Это принцип, по которому работает провайдер Configuration
.
Сначала используем, потом задаем значения.
Теперь давайте добавим файл конфигурации.
Будем использовать YAML.
Создайте пустой файл
config.yml
в корне проекта:
./
├── githubnavigator/
│ ├── templates/
│ │ ├── base.html
│ │ └── index.html
│ ├── __init__.py
│ ├── application.py
│ ├── containers.py
│ └── views.py
├── venv/
├── config.yml
└── requirements.txt
И заполните его следующими строками:
github:
request_timeout: 10
Для работы с конфигурационным файлом мы будем использовать библиотеку
PyYAML. Добавим ее в файл с зависимостями.
Отредактируйте
requirements.txt
:
dependency-injector
flask
bootstrap-flask
pygithub
pyyaml
и установите зависимость:
pip install --upgrade -r requirements.txt
Для передачи API токена мы будем использовать переменную окружения
GITHUB_TOKEN
.
Теперь нам нужно отредактировать
create_app()
чтобы сделать 2 действие при старте приложения:
- Загрузить конфигурацию из
config.yml
- Загрузить API токен из переменной окружения
GITHUB_TOKEN
Отредактируйте
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
Теперь нам нужно создать API токен.
Для это нужно:
Этот пункт можно временно пропустить.
Приложение будет работать без токена, но с ограниченной пропускной способностью. Ограничение для неаутентифицированных клиентов: 60 запросов в час. Токен нужен чтобы увеличить эту квоту до 5000 в час.
Готово.
Установка Github API клиента завершена.
Сервис поиска
Пришло время добавить сервис поиска
SearchService
. Он будет:
- Выполнять поиск на Github
- Получать дополнительные данные о коммитах
- Преобразовывать формат результат
SearchService
будет использовать Github API клиент.
Создайте пустой файл
services.py
в пакете
githubnavigator
:
./
├── githubnavigator/
│ ├── templates/
│ │ ├── base.html
│ │ └── index.html
│ ├── __init__.py
│ ├── application.py
│ ├── containers.py
│ ├── services.py
│ └── views.py
├── venv/
├── config.yml
└── requirements.txt
и добавьте в него следующие строки:
"""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,
}
Теперь добавим
SearchService
в контейнер.
Отредактируйте
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)
Подключаем поиск
Теперь мы готовы чтобы поиск заработал. Давайте используем
SearchService
в
index
представлении.
Отредактируйте
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,
)
Теперь изменим контейнер чтобы передавать зависимость
SearchService
в представление
index
при его вызове.
Отредактируйте
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,
)
Убедитесь что приложение работает или выполните
flask run
и откройте
http://127.0.0.1:5000/.
Вы увидите:
Немного рефакторинга
Наше представление
index
содержит два hardcoded значения:
- Поисковый запрос по умолчанию
- Лимит количества результатов
Давайте сделаем небольшой рефакторинг. Мы перенесем эти значения в конфигурацию.
Отредактируйте
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,
)
Теперь нам нужно чтобы эти значения передавались при вызове. Давайте обновим контейнер.
Отредактируйте
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,
)
Теперь давайте обновим конфигурационный файл.
Отредактируйте
config.yml
:
github:
request_timeout: 10
search:
default_query: "Dependency Injector"
default_limit: 10
Готово.
Рефакторинг закончен. Му сделали код чище.
Добавляем тесты
Было бы хорошо добавить немного тестов. Давайте это сделаем.
Мы будем использовать
pytest и
coverage.
Отредактируйте
requirements.txt
:
dependency-injector
flask
bootstrap-flask
pygithub
pyyaml
pytest-flask
pytest-cov
и установите новые пакеты:
pip install -r requirements.txt
Создайте пустой файл
tests.py
в пакете
githubnavigator
:
./
├── githubnavigator/
│ ├── templates/
│ │ ├── base.html
│ │ └── index.html
│ ├── __init__.py
│ ├── application.py
│ ├── containers.py
│ ├── services.py
│ ├── tests.py
│ └── views.py
├── venv/
├── config.yml
└── requirements.txt
и добавьте в него следующие строки:
"""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
Теперь давайте запустим тестирование и проверим покрытие:
py.test githubnavigator/tests.py --cov=githubnavigator
Вы увидите:
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%
Обратите внимание как мы заменяем github_client
моком с помощью метода .override()
. Таким образом можно переопределить возвращаемое значения любого провайдера.
Заключение
Мы построили Flask приложения применяя принцип dependency injection. Мы использовали Dependency Injector в качестве dependency injection фреймворка.
Основная часть нашего приложения это контейнер. Он содержит все компоненты приложения и их зависимости в одном месте. Это предоставляет контроль над структурой приложения. Её легко понимать и изменять:
"""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,
)
Контейнер как карта вашего приложения. Вы всегда знайте что от чего зависит.
Что дальше?