Flaskプロジェクトのセットアップ(TDD/Docker/Postgresなど) - Part 2
著者:Leonardo Giordani - 06/07/2020 Updated on Feb 23, 2021
はじめに
この連載では、TDD、Docker、Postgresを使って、効率性と整頓性を意識したセットアップで、Flaskプロジェクトの開発を探っています。
キャッチアップ
前回の記事では、空のプロジェクトから始めて、Flaskプロジェクトを実行するための最小限のコードを追加する方法を学びました。その後、静的な設定ファイルを作成し、flaskとdocker-composeのコマンドをラップした管理スクリプトを作成して、特定の設定でアプリケーションを実行しました。
この記事では、開発セットアップとテストの両方で、Dockerコンテナ内でコードと一緒に本番対応のデータベースを実行する方法を紹介します。
ステップ1 - データベースコンテナの追加
データベースはウェブアプリケーションには欠かせないものなので、このステップでは、私が選んだデータベースであるPostgresをプロジェクトのセットアップに追加します。これを行うには、docker-compose設定ファイルにサービスを追加する必要があります。
code: docker/development.yml
version: '3.4'
services:
db:
image: postgres
environment:
POSTGRES_DB: ${POSTGRES_DB} # Point 1
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${POSTGRES_PORT}:5432"
volumes:
- pgdata:/var/lib/postgresql/data # Point 2
web:
build:
context: ${PWD}
dockerfile: docker/Dockerfile
environment:
FLASK_ENV: ${FLASK_ENV}
FLASK_CONFIG: ${FLASK_CONFIG}
command: flask run --host 0.0.0.0
volumes:
- ${PWD}:/opt/code
ports:
- "5000:5000"
volumes:
pgdata: # Point 3
POSTGRES_で始まる変数は、PostgreSQLのDockerイメージが要求するものです。特に、POSTGRESQL_DB(Point 1)は、イメージを作成したときにデフォルトで作成されるデータベースであり、また、他のデータベースのデータも含まれているので、アプリケーションとしては、通常、別のデータベースを使用したいことを覚えておいてください。
また、コンテナを解体してもデータベースの内容が失われないように、db (Point 2、Point 3)というサービス用に永続的なボリュームを作成していることにも注目してください。このサービスには、デフォルトのイメージを使用しているので、ビルドのステップは必要ありません。
この設定をオーケストレーションするには、以下の変数をJSON構成に追加する必要があります。
code: config/development.json
[
{
"name": "FLASK_ENV",
"value": "development"
},
{
"name": "FLASK_CONFIG",
"value": "development"
},
{
"name": "POSTGRES_DB",
"value": "postgres"
},
{
"name": "POSTGRES_USER",
"value": "postgres"
},
{
"name": "POSTGRES_HOSTNAME", # Point 1
"value": "localhost"
},
{
"name": "POSTGRES_PORT",
"value": "5432"
},
{
"name": "POSTGRES_PASSWORD",
"value": "postgres"
}
]
これらはすべて開発用の変数なので、秘密はありません。本番環境では、シークレットを安全な場所に保管し、環境変数に変換する方法が必要になります。例えば、AWS Secrets Managerは、シークレットをコンテナに渡される環境変数に直接マッピングすることができ、APIでサービスに明示的に接続する必要がなくなります。
変数POSTGRES_HOSTNAME (Point 1)に気づいたかもしれませんが、これはDocker Composeファイルでは使われていません。一般的には、ユーティリティースクリプトからデータベースにアクセスできるようにしたいので、データベースが稼働しているホスト名を記録しておきたいのです。後ほど説明しますが、他のDockerコンテナでは必要ありませんが、マイグレーションでは必要になります。
ここで ./manage.py compose up -d と ./manage.py compose down というコマンドを実行して、データベースコンテナが正常に動作することを確認します。なお、最初に compose -d コマンドを実行したときには、Docker がボリュームを作成して Postgres イメージを構築するため、時間がかかる可能性があります。
code: CONSOLE
CONTAINER ID IMAGE COMMAND ... PORTS NAMES
9b5828dccd1c docker_web "flask run --host 0.…" ... 0.0.0.0:5000->5000/tcp docker_web_1
4440a18a1527 postgres "docker-entrypoint.s…" ... 0.0.0.0:5432->5432/tcp docker_db_1
次に、アプリケーションをデータベースに接続する必要がありますが、これにはflask-sqlalchemyを利用します。flask-sqlalchemyは、アプリケーションのすべての段階で使用されるので、本番環境での使用を想定しています。また、Postgresに接続するためのライブラリであるpsycopg2も必要です。
code: requirements/production.txt
Flask
flask-sqlalchemy
psycopg2
pip install -r requirements/development.txt を実行して要件をローカルにインストールし、./manage.py compose build web を実行してイメージを再構築することを忘れないでください。
この時点で、アプリケーションの設定で接続文字列を作成する必要があります。接続文字列のパラメータは、コンテナDBの起動に使用したのと同じ環境変数を使用します。
code: application/config.py
import os
class Config(object):
"""Base configuration"""
SQLALCHEMY_DATABASE_URI = (
f"postgresql+psycopg2://{user}:{password}@{hostname}:{port}/{database}"
)
SQLALCHEMY_TRACK_MODIFICATIONS = False
class ProductionConfig(Config):
"""Production configuration"""
class DevelopmentConfig(Config):
"""Development configuration"""
class TestingConfig(Config):
"""Testing configuration"""
TESTING = True
ご覧のように、ここではPOSTGRES_DBではなく、APPLICATION_DB (Point 1)という変数を使用していますので、設定ファイルでこれも指定する必要があります。その理由は、先に述べたように、Postgresが他のすべてのデータベースを管理するために使用するデフォルトのデータベースと、アプリケーションが特別に使用するデータベースとを分けたいからです。
code: config/development.json
[
{
"name": "FLASK_ENV",
"value": "development"
},
{
"name": "FLASK_CONFIG",
"value": "development"
},
{
"name": "POSTGRES_DB",
"value": "postgres"
},
{
"name": "POSTGRES_USER",
"value": "postgres"
},
{
"name": "POSTGRES_HOSTNAME",
"value": "localhost"
},
{
"name": "POSTGRES_PORT",
"value": "5432"
},
{
"name": "POSTGRES_PASSWORD",
"value": "postgres"
},
{
"name": "APPLICATION_DB",
"value": "application"
}
]
この時点で、アプリケーションコンテナは、いくつかの Postgres 環境変数と APPLICATION_DB にアクセスする必要があります。
code: docker/development.yml
version: '3.4'
services:
db:
image: postgres
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${POSTGRES_PORT}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
web:
build:
context: ${PWD}
dockerfile: docker/Dockerfile
environment:
FLASK_ENV: ${FLASK_ENV}
FLASK_CONFIG: ${FLASK_CONFIG}
APPLICATION_DB: ${APPLICATION_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_HOSTNAME: "db"
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PORT: ${POSTGRES_PORT}
command: flask run --host 0.0.0.0
volumes:
- ${PWD}:/opt/code
ports:
- "5000:5000"
volumes:
pgdata:
Postgresコンテナに渡した環境変数は、データベースへの接続に必要なため、webコンテナが受け取ることに注意してください。POSTGRES_HOSTNAMEという変数は、アプリケーションにデータベースのアドレスを与えるために渡されますが、Docker Composeの内部DNSのおかげで、単純にコンテナの名前を渡すことができます。localhostという値を渡すことはできません。コンテナ内で動作しているアプリケーションは、このアドレスを介してホストにアクセスすることはできないからです(他のネットワークモードを使用しない限り、理想的ではありません)。
composeを実行すると、FlaskとPostgresの両方が起動しますが、アプリケーションはまだデータベースに正しく接続されていません。
DBの中を見て、私たちの設定が何を生み出したかを確認してみましょう。まず、./manage.py compose up -d を実行してコンテナをスピンアップし、./manage.py compose exec db psql -U postgresでPostgres DBに接続します。ここで注意してほしいのは、-U でユーザーを指定しなければならないことです。デフォルトではrootですが、変数POSTGRES_USERでpostgresに変更しています。
次のようなコマンドラインが表示されるはずです。
code: bash
$ ./manage.py compose exec db psql -U postgres
psql (13.0 (Debian 13.0-1.pgdg100+1))
Type "help" for help.
postgres=#
また、デフォルトでは POSTGRES_DB という変数で設定された postgres というデータベースにログインしていることにも注意してください。データベースの一覧は \1
code: CONSOLE
postgres=# \l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-----------+----------+----------+------------+------------+-----------------------
postgres | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
template0 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
template1 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
(3 rows)
postgres=#
最後に、APPLICATION_DBで設定されたアプリケーションデータベースが存在しないことに注意してください。これはまだ作成していないからです。POSTGRES_で始まる環境変数はすべて、初期設定を行うためにDockerイメージが自動的に使用するもので、データベースpostgresがすでに存在するのはそのためです。
psqlはCtrl-Dまたはexitで終了できます。
Gitコミット
このステップで行った変更は、このGit コミット で確認するか、ファイルを参照 することができます。 参考資料
ステップ2 - アプリケーションとデータベースの接続
Flaskアプリケーションとコンテナ内のデータベースを接続するには、SQLAlchemyオブジェクトを初期化し、アプリケーションファクトリに追加する必要があります。
code: application/models.py
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
code: application/app.py
from flask import Flask
def create_app(config_name):
app = Flask(__name__)
config_module = f"application.config.{config_name.capitalize()}Config"
app.config.from_object(config_module)
from application.models import db
db.init_app(app)
@app.route("/")
def hello_world():
return "Hello, World!"
return app
Flaskでデータベースを管理する標準的な方法は、flask-migrateを使用することで、マイグレーションを作成して適用するためのコマンドを追加することです。
flask-migrateでは、flask db initでmigrationsフォルダを作成し、モデルを変更するたびにflask db migrate -m "Some message "とflask db upgradeを実行する必要があります。db initとdb migrateの両方がカレントディレクトリにファイルを作成するので、今度はDockerベースのセットアップが必ず直面する問題、ファイルのパーミッションに直面します。
アプリケーションはDockerコンテナ内でrootとして実行されており、コンテナ内のユーザ名空間とホストのユーザ名空間は接続されていないという状況です。その結果、Dockerコンテナがホストからマウントされているディレクトリ(この例ではアプリケーションコードが格納されているようなディレクトリ)にファイルを作成すると、それらのファイルはrootに属していると判断されてしまいます。これによって作業ができなくなるわけではありませんが(通常、開発マシンではrootになることができます)、控えめに言っても迷惑な話です。解決策は、コンテナの外からこれらのコマンドを実行することですが、そのためにはFlaskアプリケーションを設定する必要があります。
幸いなことに、私はコマンドflaskをスクリプト manage.pyでラップし、必要な環境変数をすべてロードしました。本番環境の要件にflask-migrateを追加してみましょう。
code: requirements/production.txt
Flask
flask-sqlalchemy
psycopg2
flask-migrate
pip install -r requirements/development.txt を実行して依存パッケージをローカルにインストールし、./manage.py compose build web を実行してイメージを再構築することを忘れないでください。実行ファイルであるpg_configと他のいくつかの開発ツールがシステムにインストールされている必要があることに注意してください。pip のエラーメッセージが表示された場合は、お使いのオペレーティングシステムのドキュメントを確認して、必要なパッケージをインストールするために何をすべきかを調べてください。Ubuntu Linuxでは、sudo apt install build-essential python3-dev libpq-devを実行する必要があります。
これで、Migrateオブジェクトを初期化して、アプリケーションファクトリに追加することができました。
code: application/models.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()
code: application/app.py
from flask import Flask
def create_app(config_name):
app = Flask(__name__)
config_module = f"application.config.{config_name.capitalize()}Config"
app.config.from_object(config_module)
from application.models import db, migrate
db.init_app(app)
migrate.init_app(app, db)
@app.route("/")
def hello_world():
return "Hello, World!"
return app
データベース初期化スクリプトを実行できるようになりました。
code: bash
$ ./manage.py flask db init
Creating directory /home/leo/devel/flask-tutorial/migrations ... done
Creating directory /home/leo/devel/flask-tutorial/migrations/versions ... done
Generating /home/leo/devel/flask-tutorial/migrations/env.py ... done
Generating /home/leo/devel/flask-tutorial/migrations/README ... done
Generating /home/leo/devel/flask-tutorial/migrations/script.py.mako ... done
Generating /home/leo/devel/flask-tutorial/migrations/alembic.ini ... done
Please edit configuration/connection/logging settings in '/home/leo/devel/flask-tutorial/migrations/alembic.ini' before proceeding.
そして、モデルの作成を開始する際には、./manage.py flask db migrate と ./manage.py flask db upgrade のコマンドを使用します。この記事の最後に完全な例を掲載しています。
とりあえず、ここで作成されたものを簡単に見てみましょう。db init コマンドは、migrations ディレクトリを作成し、その中にいくつかのデフォルト設定ファイルとテンプレートを作成しました。移行スクリプトは migrations/versions ディレクトリに作成されますが、現時点ではモデルがなく、移行も行わない(システムの初期化のみ)ため、このディレクトリは空です。データベースには何の変更も加えていません。db init コマンドは、コンテナを起動していなくても実行できます(ディレクトリ migrationsを削除して試してみてください)。
Gitコミット
このステップで行われた変更は、この Git コミット で確認するか、ファイルを参照 することができます。 参考資料
ステップ3 - テストのセットアップ
私はアプリケーションを開発する際にできるだけTDDアプローチを使用したいと考えています。そのため、前もって優れたテスト環境を設定する必要があり、それは可能な限り一時的なものでなければなりません。大きなプロジェクトでは、テストを実行するために明示的にインフラストラクチャコンポーネントを作成(またはスケールアップ)することは珍しくありませんが、Dockerとdocker-composeを使えば、簡単に同じことができます。具体的には、以下のようになります。
恒久的なボリュームを持たないコンテナにテスト用のデータベースを立ち上げる
初期化
すべてのテストを実行する
コンテナを解体する
この方法には、事前の設定が不要で、その場で作成したインフラで実行できるという大きなメリットがあります。しかし、アプリケーションのテスト部分が遅くなるというデメリットもあります。TDD のセットアップでは、できるだけ速くテストを実行する必要があります。しかし、データベースを含むテストは統合テストとみなされるべきで、TDDプロセスで継続的に実行されるべきではありません。これについてもっと知りたければ、このテーマで私が書いた本を読んでみてください。
このセットアップのもう一つの利点は、テスト中に他のものが必要になる可能性があることです。例えば、Celery、他のデータベース、他のサーバーなどです。これらはすべてdocker-composeファイルで作成できます。
一般的にテストとは、様々なことを行うための包括的なものです。pytest を使用するので、フルスイートを実行することができますが、特定のテストを選択したい場合は、単一のファイルに言及したり、名前のパターンマッチによってテストを選択できる強力なオプション -k を使用したりすることができます。このため、管理者のコマンドラインを pytest のものにマッピングしたいと思います。
テストカバレッジを監視するためのいくつかのパッケージと一緒に、テスト要件に pytest を追加しましょう。
code: requirements/testing.txt
-r production.txt
pytest
coverage
pytest-cov
ご覧のように、私はカバレッジ・プラグインを使用して、テストでコードをどれだけカバーしているかを監視しています。
pip install -r requirements/development.txt を実行して依存パッケージをローカルにインストールし、./manage.py compose build web を実行してイメージを再構築することを忘れないでください。
警告: manage.py スクリプトを変更する前に、./manage.py compose down を実行しているすべての実行中のコンテナを終了させてください。次のバージョンでは、コンテナの命名規則が変更されるため、古いコンテナが残ってしまったり、データベースの問題が発生する可能性があります。
code: manage.py
import os
import json
import signal
import subprocess
import time
import click
# Ensure an environment variable exists and has a value
def setenv(variable, default):
os.environvariable = os.getenv(variable, default) setenv("APPLICATION_CONFIG", "development") # Point 2
def configure_app(config): # Point 1
# Read configuration from the relative JSON file
with open(os.path.join("config", f"{config}.json")) as f:
config_data = json.load(f)
# Convert the config into a usable Python dictionary
for key, value in config_data.items():
setenv(key, value)
@click.group()
def cli():
pass
@cli.command(context_settings={"ignore_unknown_options": True})
@click.argument("subcommand", nargs=-1, type=click.Path())
def flask(subcommand):
configure_app(os.getenv("APPLICATION_CONFIG"))
cmdline = "flask" + list(subcommand) try:
p = subprocess.Popen(cmdline)
p.wait()
except KeyboardInterrupt:
p.send_signal(signal.SIGINT)
p.wait()
def docker_compose_cmdline(config): # Point 3
configure_app(os.getenv("APPLICATION_CONFIG"))
docker_compose_file = os.path.join("docker", f"{config}.yml")
if not os.path.isfile(docker_compose_file):
raise ValueError(f"The file {docker_compose_file} does not exist")
return [
"docker-compose",
"-p", # Point 4
config,
"-f",
docker_compose_file,
]
@cli.command(context_settings={"ignore_unknown_options": True})
@click.argument("subcommand", nargs=-1, type=click.Path())
def compose(subcommand):
cmdline = docker_compose_cmdline(os.getenv("APPLICATION_CONFIG")) + list(subcommand)
try:
p = subprocess.Popen(cmdline)
p.wait()
except KeyboardInterrupt:
p.send_signal(signal.SIGINT)
p.wait()
@cli.command()
@click.argument("filenames", nargs=-1)
def test(filenames):
configure_app(os.getenv("APPLICATION_CONFIG"))
cmdline = docker_compose_cmdline(os.getenv("APPLICATION_CONFIG")) + "up", "-d" subprocess.call(cmdline)
cmdline = docker_compose_cmdline(os.getenv("APPLICATION_CONFIG")) + "logs", "db" logs = subprocess.check_output(cmdline)
while "ready to accept connections" not in logs.decode("utf-8"): # Point 6
time.sleep(0.1)
logs = subprocess.check_output(cmdline)
cmdline.extend(filenames)
subprocess.call(cmdline)
cmdline = docker_compose_cmdline(os.getenv("APPLICATION_CONFIG")) + "down" subprocess.call(cmdline)
if __name__ == "__main__":
cli()
注目すべき変更点は
環境設定のコードが関数configure_app (Pont 1)になりました。これにより、スクリプト(Point 2)の内部で変数 APPLICATION_CONFIG を強制的に設定してから環境を設定することができ、APPLICATION_CONFIG=testing flask test でテストを呼び出す必要がなくなりました。
flaskとcomposeの両コマンドは、configuration developmentを使用します。APPLICATION_CONFIGという変数のデフォルト値なので、configure_appという関数を呼べばいいのです。
docker-compose コマンドラインは compose コマンドと test コマンドの両方で必要なので、いくつかのコードを docker_compose_cmdline(Point 3) という関数に分離し、サブプロセス関数が必要とするリストを返すようにしました。このコマンドラインでは、コンテナにプレフィックスを与えるために、-p (project name)(Point 4) というオプションも使えるようになりました。これにより、開発サーバーを実行しながらテストを実行することができます。
test コマンドは APPLICATION_CONFIG を testing (Point 5) に設定し、config/testing.json ファイルを読み込み、docker/testing.yml ファイルを使用して docker-compose を実行し (両方のファイルともまだ作成されていません)、pytest コマンドラインを実行し、testing データベースコンテナを破棄します。テストを実行する前に、スクリプトはサービスが利用可能になるのを待ちます(Point 6)。Postgres は、データベースが受け入れ可能な状態になるまで、接続を許可しません。
code: config/testing.json
[
{
"name": "FLASK_ENV",
"value": "production"
},
{
"name": "FLASK_CONFIG",
"value": "testing"
},
{
"name": "POSTGRES_DB",
"value": "postgres"
},
{
"name": "POSTGRES_USER",
"value": "postgres"
},
{
"name": "POSTGRES_HOSTNAME",
"value": "localhost" # Point 2
},
{
"name": "POSTGRES_PORT",
"value": "5433" # Point 1
},
{
"name": "POSTGRES_PASSWORD",
"value": "postgres"
},
{
"name": "APPLICATION_DB",
"value": "test"
}
]
ここでは、POSTGRES_PORT に 5433 を指定しています(Point 1)。これにより、開発用のコンテナが稼働している間にテスト用のデータベースコンテナを起動することができます。より一般的な解決策としては、Dockerにコンテナ用のランダムなホストポートを選ばせ、それを使用することが考えられますが、これを適切に実装するにはもう少しコードが必要なので、シナリオを設定する際にこの問題に戻ってきます。
また、このファイルで変数POSTGRES_HOSTNAMEをlocalhostに設定していること(Point 2)に注意してください。テストはコンテナ内ではなくローカルマシンで実行するので、Docker Composeが提供するDNSは使用できません。
最後に必要な設定は、Docker Composeのオーケストレーション設定です。
code: docker/testing.yml
version: '3.4'
services:
db:
image: postgres
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${POSTGRES_PORT}:5432"
これで、./manage.py test を実行すると、次のようになります。
code: CONSOLE
Creating network "testing_default" with the default driver
Creating testing_db_1 ... done
========================== test session starts =========================
platform linux -- Python 3.7.5, pytest-5.4.3, py-1.8.2, pluggy-0.13.1
-- /home/leo/devel/flask-tutorial/venv3/bin/python3
cachedir: .pytest_cache
rootdir: /home/leo/devel/flask-tutorial
plugins: cov-2.10.0
collected 0 items
Coverage.py warning: No data was collected. (no-data-collected)
----------- coverage: platform linux, python 3.7.5-final-0 -----------
Name Stmts Miss Cover Missing
-----------------------------------------------------
application/app.py 11 11 0% 1-21
application/config.py 13 13 0% 1-31
application/models.py 4 4 0% 1-5
-----------------------------------------------------
TOTAL 28 28 0%
======================== no tests ran in 0.07s =======================
Stopping testing_db_1 ... done
Removing testing_db_1 ... done
Removing network testing_default
このコマンドでは、まずテスト用データベースコンテナtesting_db_1を作成し、次にpytestを実行し、最後にコンテナを停止して削除していることに注意してください。これはまさに、テストを分離して実行するために実現したかったことです。しかし、現時点ではテストは存在せず、テスト用のデータベースも空っぽです。
Git コミット
この手順で行った変更内容は、Git コミット で確認することができます。また、ファイルを参照 することもできます。 参考資料
pytest: 全機能を備えた Python テストフレームワーク ステップ4 - テスト用データベースの初期化
Web アプリケーションを開発して本番環境で運用する場合、通常は一度データベースを作成してからマイグレーションでアップグレードします。テストを実行する際には、毎回データベースを作成する必要があるため、pytest を実行する前にテスト用のデータベースで SQL コマンドを実行する方法を追加する必要があります。
SQLコマンドをデータベース上で直接実行することはよくあることなので、接続のための定型文をラップする関数を作成します。この時点で初期のデータベースを作成するコマンドは些細なものになります。
code: manage.py
import os
import json
import signal
import subprocess
import time
import click
import psycopg2
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
# Ensure an environment variable exists and has a value
def setenv(variable, default):
os.environvariable = os.getenv(variable, default) setenv("APPLICATION_CONFIG", "development")
def configure_app(config):
# Read configuration from the relative JSON file
with open(os.path.join("config", f"{config}.json")) as f:
config_data = json.load(f)
# Convert the config into a usable Python dictionary
for key, value in config_data.items():
setenv(key, value)
@click.group()
def cli():
pass
@cli.command(context_settings={"ignore_unknown_options": True})
@click.argument("subcommand", nargs=-1, type=click.Path())
def flask(subcommand):
configure_app(os.getenv("APPLICATION_CONFIG"))
cmdline = "flask" + list(subcommand) try:
p = subprocess.Popen(cmdline)
p.wait()
except KeyboardInterrupt:
p.send_signal(signal.SIGINT)
p.wait()
def docker_compose_cmdline(config):
configure_app(os.getenv("APPLICATION_CONFIG"))
docker_compose_file = os.path.join("docker", f"{config}.yml")
if not os.path.isfile(docker_compose_file):
raise ValueError(f"The file {docker_compose_file} does not exist")
return [
"docker-compose",
"-p",
config,
"-f",
docker_compose_file,
]
@cli.command(context_settings={"ignore_unknown_options": True})
@click.argument("subcommand", nargs=-1, type=click.Path())
def compose(subcommand):
cmdline = docker_compose_cmdline(os.getenv("APPLICATION_CONFIG")) + list(subcommand)
try:
p = subprocess.Popen(cmdline)
p.wait()
except KeyboardInterrupt:
p.send_signal(signal.SIGINT)
p.wait()
def run_sql(statements):
conn = psycopg2.connect(
dbname=os.getenv("POSTGRES_DB"),
user=os.getenv("POSTGRES_USER"),
password=os.getenv("POSTGRES_PASSWORD"),
host=os.getenv("POSTGRES_HOSTNAME"),
port=os.getenv("POSTGRES_PORT"),
)
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cursor = conn.cursor()
for statement in statements:
cursor.execute(statement)
cursor.close()
conn.close()
@cli.command()
def create_initial_db(): # Point 1
configure_app(os.getenv("APPLICATION_CONFIG"))
try:
except psycopg2.errors.DuplicateDatabase:
print(
f"The database {os.getenv('APPLICATION_DB')} already exists and will not be recreated"
)
@cli.command()
@click.argument("filenames", nargs=-1)
def test(filenames):
configure_app(os.getenv("APPLICATION_CONFIG"))
cmdline = docker_compose_cmdline(os.getenv("APPLICATION_CONFIG")) + "up", "-d" subprocess.call(cmdline)
cmdline = docker_compose_cmdline(os.getenv("APPLICATION_CONFIG")) + "logs", "db" logs = subprocess.check_output(cmdline)
while "ready to accept connections" not in logs.decode("utf-8"):
time.sleep(0.1)
logs = subprocess.check_output(cmdline)
cmdline.extend(filenames)
subprocess.call(cmdline)
cmdline = docker_compose_cmdline(os.getenv("APPLICATION_CONFIG")) + "down" subprocess.call(cmdline)
if __name__ == "__main__":
cli()
ご覧のように、私は create_initial_db というコマンド(Point 1)を書く機会を得ました。これはテスト用のデータベースを作成するのと全く同じ SQL コマンドを実行するだけですが、私が使用するあらゆる構成に対応しています。
先に進む前に、ファイル manage.py をリファクタリングする必要があると思います。リファクタリングは必須ではありませんが、スクリプトのいくつかの部分は十分に汎用的ではないと感じていますし、シナリオを追加する際には、関数を柔軟にする必要があるでしょう。
新しいスクリプトは次のようになります。
code: manage.py
import os
import json
import signal
import subprocess
import time
import click
import psycopg2
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
# Ensure an environment variable exists and has a value
def setenv(variable, default):
os.environvariable = os.getenv(variable, default) setenv("APPLICATION_CONFIG", "development")
APPLICATION_CONFIG_PATH = "config"
DOCKER_PATH = "docker"
def app_config_file(config): # Point 1
return os.path.join(APPLICATION_CONFIG_PATH, f"{config}.json")
def docker_compose_file(config): # Point 2
return os.path.join(DOCKER_PATH, f"{config}.yml")
def configure_app(config):
# Read configuration from the relative JSON file
with open(app_config_file(config)) as f:
config_data = json.load(f)
# Convert the config into a usable Python dictionary
for key, value in config_data.items():
setenv(key, value)
@click.group()
def cli():
pass
@cli.command(context_settings={"ignore_unknown_options": True})
@click.argument("subcommand", nargs=-1, type=click.Path())
def flask(subcommand):
configure_app(os.getenv("APPLICATION_CONFIG"))
cmdline = "flask" + list(subcommand) try:
p = subprocess.Popen(cmdline)
p.wait()
except KeyboardInterrupt:
p.send_signal(signal.SIGINT)
p.wait()
def docker_compose_cmdline(commands_string=None): # Point 4
config = os.getenv("APPLICATION_CONFIG")
configure_app(config)
compose_file = docker_compose_file(config)
if not os.path.isfile(compose_file):
raise ValueError(f"The file {compose_file} does not exist")
command_line = [
"docker-compose",
"-p",
config,
"-f",
compose_file,
]
if commands_string:
command_line.extend(commands_string.split(" "))
return command_line
@cli.command(context_settings={"ignore_unknown_options": True})
@click.argument("subcommand", nargs=-1, type=click.Path())
def compose(subcommand):
cmdline = docker_compose_cmdline() + list(subcommand)
try:
p = subprocess.Popen(cmdline)
p.wait()
except KeyboardInterrupt:
p.send_signal(signal.SIGINT)
p.wait()
def run_sql(statements):
conn = psycopg2.connect(
dbname=os.getenv("POSTGRES_DB"),
user=os.getenv("POSTGRES_USER"),
password=os.getenv("POSTGRES_PASSWORD"),
host=os.getenv("POSTGRES_HOSTNAME"),
port=os.getenv("POSTGRES_PORT"),
)
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cursor = conn.cursor()
for statement in statements:
cursor.execute(statement)
cursor.close()
conn.close()
def wait_for_logs(cmdline, message): # Point 3
logs = subprocess.check_output(cmdline)
while message not in logs.decode("utf-8"):
time.sleep(0.1)
logs = subprocess.check_output(cmdline)
@cli.command()
def create_initial_db():
configure_app(os.getenv("APPLICATION_CONFIG"))
try:
except psycopg2.errors.DuplicateDatabase:
print(
f"The database {os.getenv('APPLICATION_DB')} already exists and will not be recreated"
)
@cli.command()
@click.argument("filenames", nargs=-1)
def test(filenames):
configure_app(os.getenv("APPLICATION_CONFIG"))
cmdline = docker_compose_cmdline("up -d")
subprocess.call(cmdline)
cmdline = docker_compose_cmdline("logs db")
wait_for_logs(cmdline, "ready to accept connections")
cmdline.extend(filenames)
subprocess.call(cmdline)
cmdline = docker_compose_cmdline("down")
subprocess.call(cmdline)
if __name__ == "__main__":
cli()
注目すべき変更点
ファイルパスの作成をカプセル化する2つの新しい関数app_config_file(Point 1)とdocker_compose_file (Point 2)を作成しました。
データベースコンテナのログでメッセージを待つコードを分離し、wait_for_logs(Point 3) という関数を作成しました。
コマンドdocker_compose_cmdline(Point 4)は、文字列を受け取り、内部でリストに変換するようになりました。この方法では、subprocess が扱う醜いリスト構文を必要としないので、コマンドをより自然に表現することができます。
Git コミット
このステップで行われた変更は、この Git コミット で確認するか、ファイルを参照 することができます。 参考資料
Psycopg: Python用のPostgreSQLデータベースアダプタ ステップ5:テスト用フィクスチャ
Pytestではテスト用のフィクスチャを使用しますので、一般的に役立つ基本的なフィクスチャを用意しましょう。
まず、pytest-flask を追加しましょう。pytest-flask はすでにいくつかの基本的なフィクスチャを提供しています。
code: requirements/testing.txt
-r production.txt
pytest
coverage
pytest-cov
pytest-flask
次に、tests/conftest.pyというファイルにフィクスチャのアプリとデータベースを追加します。前者はpytest-flask自体が必要とするもので(他のフィクスチャでも使用されます)、後者はデータベース自体を操作する必要があるときに役立ちます。
code: tests/conftest.py
import pytest
from application.app import create_app
from application.models import db
@pytest.fixture
def app():
app = create_app("testing")
return app
@pytest.fixture(scope="function")
def database(app):
with app.app_context():
db.drop_all()
db.create_all()
yield db
pytest がコードを正しく読み込むために、空のファイル tests/__init__.py を作成することを忘れないでください。
ご覧のとおり、フィクスチャデータベースは drop_all と create_all というメソッドを使ってリセットしています。その理由は、このフィクスチャは関数ごとに再作成されるので、前の関数がデータベースをきれいに残しているかどうかはわからないからです。実際のところ、ほとんど反対のことを確信しているかもしれません。
Git コミット
このステップで行われた変更は、この Git コミット で確認できるほか、ファイルを参照 することもできます。 参考資料
ボーナスステップ - TDDの完全な例
この記事を終える前に、アプリケーションの開発を開始するのに十分なセットアップが完了している現在の状態で、私が行うTDDプロセスの完全な例を紹介したいと思います。私の目標は、ID(プライマリキー)とEメールのフィールドを持つUserモデルを追加することだとします。
まず最初に、データベースにユーザーを作成し、その属性をチェックしながらユーザーを取得するテストを書きます。
code: tests/test_user.py
from application.models import User
def test__create_user(database):
email = "some.email@server.com"
user = User(email=email)
database.session.add(user)
database.session.commit()
user = User.query.first()
assert user.email == email
このテストを実行すると、モジュール「User」が存在しないため、エラーになります。
code: bash
$ ./manage.py test
Creating network "testing_default" with the default driver
Creating testing_db_1 ... done
======================================= test session starts ======================================
platform linux -- Python 3.7.5, pytest-5.4.3, py-1.9.0, pluggy-0.13.1 --
/home/leo/devel/flask-tutorial/venv3/bin/python3
cachedir: .pytest_cache
rootdir: /home/leo/devel/flask-tutorial
plugins: flask-1.0.0, cov-2.10.0
collected 0 items / 1 error
============================================= ERRORS =============================================
___________________________ ERROR collecting tests/tests/test_user.py ___________________________
ImportError while importing test module '/home/leo/devel/flask-tutorial/tests/tests/test_user.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
venv3/lib/python3.7/site-packages/_pytest/python.py:511: in _importtestmodule
mod = self.fspath.pyimport(ensuresyspath=importmode)
venv3/lib/python3.7/site-packages/py/_path/local.py:704: in pyimport
__import__(modname)
venv3/lib/python3.7/site-packages/_pytest/assertion/rewrite.py:152: in exec_module
exec(co, module.__dict__)
tests/tests/test_user.py:1: in <module>
from application.models import User
E ImportError: cannot import name 'User' from 'application.models'
(/home/leo/devel/flask-tutorial/application/models.py)
----------- coverage: platform linux, python 3.7.5-final-0 -----------
Name Stmts Miss Cover Missing
-----------------------------------------------------
application/app.py 11 9 18% 6-21
application/config.py 14 14 0% 1-32
application/models.py 4 0 100%
-----------------------------------------------------
TOTAL 29 23 21%
==================================== short test summary info ===================================
ERROR tests/tests/test_user.py
!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!
======================================= 1 error in 0.20s =======================================
Stopping testing_db_1 ... done
Removing testing_db_1 ... done
Removing network testing_default
$
ここでは、厳密なTDD方法論のすべてのステップを示すことはせず、最終的なソリューションを直接実装します。
code: application/models.py
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String, unique=True, nullable=False)
このモデルでは、テストはパスします。
code: bash
$ ./manage.py test
Creating network "testing_default" with the default driver
Creating testing_db_1 ... done
=================================== test session starts ==================================
platform linux -- Python 3.7.5, pytest-5.4.3, py-1.9.0, pluggy-0.13.1 --
/home/leo/devel/flask-tutorial/venv3/bin/python3
cachedir: .pytest_cache
rootdir: /home/leo/devel/flask-tutorial
plugins: flask-1.0.0, cov-2.10.0
collected 1 item
tests/test_user.py::test__create_user PASSED
----------- coverage: platform linux, python 3.7.5-final-0 -----------
Name Stmts Miss Cover Missing
-----------------------------------------------------
application/app.py 11 1 91% 19
application/config.py 14 0 100%
application/models.py 8 0 100%
-----------------------------------------------------
TOTAL 33 1 97%
==================================== 1 passed in 0.14s ===================================
Stopping testing_db_1 ... done
Removing testing_db_1 ... done
Removing network testing_default
$
これは非常に単純な例であり、実際のケースではこのコードを受け入れる前に他のテストを追加することになることをご了承ください。特に、emailというフィールドが空でもよいかどうかをチェックし、そのフィールドのバリデーションもテストするべきでしょう。
新しく作成したモデルを使用する非常にシンプルなルートを追加してみましょう。
code: application/app.py
from flask import Flask
from application.models import User
def create_app(config_name):
app = Flask(__name__)
config_module = f"application.config.{config_name.capitalize()}Config"
app.config.from_object(config_module)
from application.models import db, migrate
db.init_app(app)
migrate.init_app(app, db)
@app.route("/")
def hello_world():
return "Hello, World!"
@app.route("/users")
def users():
num_users = User.query.count()
return f"Number of users: {num_users}"
return app
ご覧の通り、あまり複雑なことはしていません。モデルUserをインポートして、そのテーブルのエントリ数をカウントします。テーブルはflask db migrateが作成してくれるマイグレーションですぐに作成しますので、"Number of users. "というページが返ってくるだけだと予想しています。0」というページが返ってくるだけだと思いますが、データベースとの接続が機能していることを示す良いデモンストレーションになります。
それでは、データベースにマイグレーションを生成してみましょう。で開発環境を立ち上げます。
code: bash
$ ./manage.py compose up -d
初めて環境を立ち上げる場合は、アプリケーション・データベースを作成し、マイグレーションを初期化しなければならないので、次のように実行します。
code: bash
$ ./manage.py create-initial-db
前にAlembicを初期化したので、db initコマンドを実行する必要はありません。実行するとErrorが返ってきます。ディレクトリmigrationsは既に存在し、空ではありません。これで、以下の方法でマイグレーションを作成することができます。
code: bash
$ ./manage.py flask db migrate -m "Initial user model"
Generating /home/leo/devel/flask-tutorial/migrations/versions/7a09d7f8a8fa_initial_user_model.py ... done
出力結果を見ると、migrations/versions/7a09d7f8a8fa_initial_user_model.py というファイルが作成されていることがわかります。7a09d7f8a8faという数字はUUIDを16進数にしたものなので、あなたにとっては違うものになるでしょうし、名前はコミットメッセージから来ています。このファイル自体にはSQLAlchemyのコードが含まれており、アプリケーションに書いたコードに従ってDBを変更します。
最後に、マイグレーションを適用するために
code: bash
$ ./manage.py flask db upgrade
この時点で、./manage.py compose exec db psql -U postgres を再度実行し、データベースに何が起こったかを確認することができます。
code: bash
$ ./manage.py compose exec db psql -U postgres
psql (13.0 (Debian 13.0-1.pgdg100+1))
Type "help" for help.
postgres=# \l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-------------+----------+----------+------------+------------+-----------------------
application | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
postgres | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
template0 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
template1 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
(4 rows)
ここでは、APPLICATION_DBで設定されたデータベースアプリケーションが作成されていることがわかります。これで、データベースに接続して、テーブルをリストアップできます。
code: CONSOLE
postgres=# \c application
You are now connected to database "application" as user "postgres".
application=# \dt
List of relations
Schema | Name | Type | Owner
--------+-----------------+-------+----------
public | alembic_version | table | postgres
public | users | table | postgres
(2 rows)
alembic_version テーブルの内容は、移行に使用されるUUIDであるため、驚くべきことではありません。
code: CONSOLE
application=# select * from alembic_version;
version_num
--------------
7a09d7f8a8fa
(1 row)
usersテーブルには、Pythonで作成したモデルに従って、idとemailのフィールドが含まれています。
code: CONSOLE
application=# \d users
Table "public.users"
Column | Type | Collation | Nullable | Default
--------+-------------------+-----------+----------+-----------------------------------
id | integer | | not null | nextval('users_id_seq'::regclass)
email | character varying | | not null |
Indexes:
"users_pkey" PRIMARY KEY, btree (id)
"users_email_key" UNIQUE CONSTRAINT, btree (email)
また、ブラウザを開いて http://localhost:5000/users にアクセスすると、新しいルートが動作しているのを確認できます。この後、私のコードを安全にコミットして、次の要件に進むことができます。
Git コミット
このステップで行われた変更は、このGitコミット で確認することができます。また、ファイルを参照 することもできます。 最後に
この記事では、良いセットアップが違いを生む理由がお分かりいただけたと思います。このプロジェクトはクリーンで、コマンドを管理スクリプトでラップし、設定を一元化したことは、移行とテストの問題をエレガントな方法で解決することができ、良い選択であったと思います。次回の記事では、データベース内の特定のデータのみを使用してクエリをテストするシナリオを簡単に作成する方法を紹介します。私の記事が役に立ったら、興味を持ってくれそうな人たちにシェアしてください。
Flask project setup series
日本語訳: Flaskプロジェクトのセットアップ(TDD/Docker/Postgresなど) - Part 2