アプリをインストールに対応させる
インストール対応
インストール対応のメリット
- どのディレクトリからも実行できる
- バージョン管理システムで依存関係を管理できる
- テストツールで、開発環境とテスト環境を区分できる
プロジェクト環境の記載
pyproject.tomlファイルを生成
[project]
name = "flaskr"
version = "1.0.0"
description = "The basic blog app built in the Flask tutorial"
dependencies = [
"flask",
]
[build-system]
requires = ["flit_core<4"]
build-backend = "flit_core.buildapi"
pyproject.tomlをどこに置けばいいか :
回答 : パッケージと同階層におきましょう
my-flask-app/
├── flaskr/
│ ├── __init__.py
│ └── ...
├── pyproject.toml
└── ...
プロジェクトのインストール(pip)
tomlファイルを作成したら,次のコマンドでpipにEditableな形でインストールしましょう。(実行はプロジェクトのルートディレクトリにて行う)
pip install e .
これにより、pipの中にPATHを持つパッケージが反映されたら成功です。
~/De/d/flask_d/t/flask-tutorial > pip list py tutorial py base
Package Version Editable project location
------------ ------- --------------------------------------------------------------------
blinker 1.8.2
click 8.1.7
Flask 3.0.3
flaskr 1.0.0 /path/to/flask-tutorial
itsdangerous 2.2.0
Jinja2 3.1.4
MarkupSafe 2.1.5
pip 23.2.1
setuptools 65.5.0
Werkzeug 3.0.3
Editableモードとは
pip install -e .
により開発モードでパッケージをインストールしました。
メリット :
-
開発モードでのインストール:
- ローカルの変更が即座に反映される
- メタデータの変更(依存関係が変更)のみ再インストールが必要
-
シンボリックリンク:
- パッケージのディレクトリがシンボリックリンクされるため、インストール後もソースコードを直接変更できます
シンボリックリンク :
ファイルシステムにおける環境変数の様なもの。特定のPATHを別のPATHとして紐付けをさせて保存できる。
ln -s /path/to/original /path/to/link
上記の処理をすることでどこのディレクトリからでもアプリを実行できる様になります。
(*筆者はホームディレクトリからの実行を確認しました)
flask --app flaskr run --debug
検証作業の範囲について
test client
FlaskではアプリケーションにRequestを送り、受けとったresponseデータを戻してくれる test clientという機能がある。
テスト範囲
できるだけ可能な限りを網羅するテストが必要。各関数, 条件分岐など100%を網羅する必要がある。しかし、ユーザーの想定外の動作はあり得るので100%のバグがないということは難しい。
pytest / coverageをインストール
ここからの操作はpytestとcoverageというライブラリを利用するので、pip installする
pip install pytest coverage
各機能をテストしていく
環境設定 :
- flaskrのパッケージの同階層に
tests
ディレクトリを設置 -
tests/conftest.py
ファイルにはこれ以降のテストが利用する設定関数(setup func)を含んでいる。この機能をfixtures
という。 - Pythonモジュールに含まれる、
test_
から始まる関数などを用いて検証をしていく。
テスト用のデータを生成
INSERT INTO user (username, password)
VALUES
('test', 'pbkdf2:sha256:50000$TCI4GzcX$0de171a4f4dac32e3364c7ddc7c14f3e2fa61f2d17574483f7ffbb431b4acb2f'),
('other', 'pbkdf2:sha256:50000$kJPKsz6N$d2d4784f1b030a9761f5ccaeeaca413f27f2ecb76d6168407af962ddce849f79');
INSERT INTO post (title, body, author_id, created)
VALUES
('test title', 'test' || x'0a' || 'body', 1, '2018-01-01 00:00:00');
||ってなんだろう。 :
bodyに追加されている'test' || x'0a' || 'body'の||は文字列を結合している部分。そして、x'0a'は、16進数リテラル(エスケープキーが使えない時の記述)を使い、改行を示します。
テスト用のセットアップ(アプリケーションインスタンス)
次のコードでは一時的にテストで使う以下の要素のフィクスチャ(fixture)を設定します。
fixtureとは:
テスト環境を準備するためのセットアップ手順を提供する仕組み
pytestライブラリを使って、 @pytest.fixture デコレーターを使って宣言される関数のこと。
fixtureには、準備・提供・クリーンアップの3つの処理が備わっている
import os
import tempfile
import pytest # fixtureを作成するのに利用するライブラリ
from flaskr import create_app
from flaskr.db import get_db, init_db
with open(os.path.join(os.path.dirname(__file__), 'data.sql'),'rb') as f:
_data_sql = f.read().decode('utf8')
@pytest.fixture
def app():
db_fd, db_path = tempfile.mkstemp()
# mkstemp() : ユニークな名前の一時ファイルを作成し、そのpathとファイルディスクリプターを返すメソッド
# ファイルディスクリプター(OSがファイルを特定するためのIDの様なもの。)
app = create_app(
'TESTING': True,
'DATABASE': db_path,
)
with app.app_context():
init_db()
get_db().executescript(_data_sql)
yield app # *ブログで説明
os.close(db_fd)
os.unlink(db_path)
@pytest.fixture
def client(app):
return app.test_client()
@pytest.fixture
def runner(app):
return app.test_cli_runner()
appフィクスチャ
with app.app_context():
init_db()
get_db().executescript(_data_sql)
yield app # *ブログで説明
os.close(db_fd)
os.unlink(db_path)
with app.app_context():の処理
アプリケーションコンテキストの設定。データベースを初期化して、テスト用データをダウンロードする。
※コンテキストという概念
flaskには2つのコンテキストがある
- App context : Flaskアプリ全体に関連する情報(DB, config)
- Request context : 各Httpリクエストに関連する情報
contextとは、特定の処理中に必要な情報や状態を一時的に保持して効率的にアクセスする仕組み。
コンテキストはHttpリクエストの処理が完了すると破棄される
-
App contextの例 :
- current_app : 今のFlaskアプリケーションインスタンスへの参照
- g : リクエストごとのグローバル変数
-
Request contextの例 :
- request :
- 現在のリクエストオブジェクト
- session :
- 現在のセッションオブジェクト
- request :
yieldという概念 :
yield app
os.close(db_fd)
os.unlink(db_path)
yieldはreturnと同じ様な使い方をするが、returnは関数を全て計算した上で戻り値を返すが、yieldは処理を一時停止して戻り値を返す。
このコードの中の yield appの後には,os.close()やos.unlink()といった一時ファイルを削除する様な処理を含んでいる。
つまり、初めに呼び出すときにはテスト用のappインスタンスを戻し、次に呼び出せばテスト環境をクローズする、フィクスチャのサイクルに準じた設定になっているのだ。
withという概念 :
with open('file.txt','r') as f:
data = file.read()
コンテキストマネージャを扱う構文で、ファイルなど特定のオブジェクトに対して、リソースの終了時には自動でクリーンアップを行い、リソースリークを防ぐ。
Client fixture
@pytest.fixture
def client(app):
return app.test_client()
サーバーを動かさずに、クライアント(Requestを送る人)を生み出している。
Runner fixture
@pytest.fixture
def runner(app):
return app.test_cli_runner()
アプリケーションに対して、クリックイベントを送るランナーを生成している。
pytestについて
pytestはfixtureで定義した関数名と、テスト関数の引数名を紐づけている。例えば、テスト関数 test_hello()
関数は、client
という引数を持つとする。
pytestでは、fixtureにおける client関数
を呼び出し、テスト関数にその戻り値を引き渡す。
Factoryのテスト
Factoryメソッドは他のほとんどのコードで使われているので、もしもエラーがあれば報告されるはずなので、注視してテストをすることはない。
from flaskr import create_app
def test_config():
# デフォルトのappインスタンスのtesting属性がFalseかどうか
assert not create_app().testing
# test_configが上書きされて、testing属性がTrueかどうか
assert create_app({'TESTING' : True}).testing
def test_hello(client):
response = client.get('/hello')
# response.data : HTTPレスポンスの本体部分をbite列で返す
# bは文字列をバイト列として読み込ませる記法
assert response.data == b'Hello, World!!'
__init__.pyの /hello ルートに対する挙動を検査している
@app.route('/hello')
def hello():
return 'Hello, World!!'
assertという概念:
後述の条件がTrueかどうかを判定する。FalseであればAssertionErrorを出す
バイト列リテラル :
b'Hello,World'
でも利用している b
は後の文字列をbite列リテラルとして解釈させるためのプレフィックスである。
バイト列リテラルは Python3で導入された概念。これにより文字エンコーディングに影響なくバイト単位で操作が可能となった
Databaseのテスト
アプリの実行中は get_db
関数からは同じ接続オブジェクトを返し、アプリが閉じられるとその接続も閉じられるべきである。
import sqlite3
import pytest
from flaskr.db import get_db
def test_get_close_db(app):
# 1回目と2回目のdbの値を比べる
# 接続は何度よびだしても同じ値になっているべき
with app.app_context():
db = get_db()
assert db is get_db()
# app_contextが閉じた後は接続も閉じられてるかを確認
# もし閉じられてる状態でdb.executeするとsqlite3.ProgrammingErrorエラーが起きる
with pytest.raises(sqlite3.ProgrammingError) as e:
db.execute('SELECT 1')
# 発生したエラーに'closed'という文字があることを確認
assert 'closed' in str(e.value)
with app.app_context()ブロック
:
明示的にapp_contextを開始している。このブロック内の処理が終わったらapp_contextは閉じられる。
# データベースを初期化するコマンドの実行を確認している。
def test_init_db_command(runner, monkeypatch):
# init_db関数が呼び出されたかどうかを確認するフラグcalledを持つクラス
class Recorder(object):
called=False
# 実際の init_db関数の代わりに使用されるフェイク関数
# 実行すると calledをTrueにする
def fake_init_db():
Recorder.called=True
# monkeypatchは関数を別の関数として置換する
monkeypatch.setattr('flaskr.db.init_db',fake_init_db)
# runner.invokeを使って init-dbコマンドを実行
result = runner.ivoke(args=['init-db'])
# コマンドの出力結果に 'Initialized' が含まれているかを確認する
assert 'Initialized' in result.output
# fake_init_dbが実行されて Recorder.calledがTrueになていることを確認する
assert Recorder.called
monkeypatchフィクスチャ
:
Pytestのmonkeypatchインスタンスは、init_db関数を呼ばれたことを記録する関数に置き換えます。
runnerフィクスチャ
:
init_db関数を名前で呼び出すのに使われています。
Authentication
LoginとRegisterの検証
conftest.pyの作成
ログインの検証にはクライアントを使ってPOSTリクエストをlogin viewに送信する方法が取られる。この機能を持ったクラスを作成し、フィクスチャを使ってクライアント側に毎回引き渡す。
class AuthActions(object):
def __init__(self,client):
self._client = client
def login(self, username='test', password='test'):
return self._client.post(
'/auth/login',
data={'username':username, 'password':password}
)
def logout(self):
return self._client.get('/auth/logout')
@pytest.fixture
def auth(client):
return AuthActions(client)
auth fixture
:
テストユーザとしてログインするためにauth.login()
を使うために作成したfixture
self引数って何? :
AuthActionsクラスで利用されるメソッドの __init__
やlogin
関数の引数selfは、クラス内のメソッドにはインスタンスオブジェクトを第一引数として受け取らせる必要がある。これをselfと呼ぶ。
(位置引数なので他の名前でも良い。)
AuthActionsのクラス定義に渡される object
引数 :
これはPython2だとクラス定義に必須の引数。でもPython3からはあってもなくてもいいみたい。
クラスメソッドは(self, {何かの引数}) :
インスタンス化するときには明示的にインスタンスを入れなくても、
インスタンス自身を引数にする。
すでに第一引数は自分自身が入るので、AuthActions(clientインスタンス)としたときにちゃんとコンストラクター__init__(self, client)
の第二引数にclient
インスタンスが入る。
import pytest
from flask import g, session
from flaskr.db import get_db
def test_register(client, app):
# getリクエストの正しいレンダリングが返ってきた場合 => 200
# 何かしらのエラーが起きた場合 => 500 Internal Server Error
assert client.get('/auth/register').status_code == 200
response = client.post(
# 以下のユーザ情報で登録した後にFlaskは /auth/loginを受け取る
'/auth/register', data={'username':'a', 'password':'a'}
)
# Register viewからLogin viewへのリダイレクトURLを確認
assert response.headers['Location'] == '/auth/login'
# 登録したユーザaがデータベースに入っているかを確認
with app.app_context():
assert get_db().execute(
"SELECT * FROM user WHERE username='a",
).fetchone() is not None
@pytest.mark.parametrize(('username','password','message'),(
('','',b'Username is required.'),
('a','',b'Password is required.'),
('test', 'test', b'already registered'),
))
def test_register_validate_input(client, username, password, message):
response = client.post(
'/auth/register',
data = {'username':username, 'password':password}
)
assert message in response.data
client.get() , client.post()
:
Flaskアプリケーションからそれぞれのメソッドに応じたResponseを受け取る関数。
client.post()の 辞書型のデータはフォームデータになる。
pytest.mark.parametrizeデコレーター
:
Pytestに同じテストを他の異なる値を用いて繰り返させるための記述。
test_register_validate_input()関数に対して、3種類のテストを実行する。
これらのテストの内容は、Username, passwordへ不正な3種類の値を入れてそのエラーメッセージが想定内のものかを確認している。
@pytest.markparametrizeのmessageはなぜByte型にしているのか :
=> response.dataの中身は全てByte型で返されるため。
*もし、response.dataの中身を文字列として比較したい場合は response.get_data(as_text=True)
として取得ができる
def test_login(client, auth):
assert client.get('/auth/login').status_code == 200
response = auth.login()
assert response.headers["Location"] == "/"
with client:
client.get('/')
assert session['user_id']==1
assert g.user['username']=='test'
@pytest.mark.parametrize(('username','password','message'),(
('a', 'test', b'Incorrect username.'),
('test', 'a', b'Incorrect password'),
))
def test_login_validate_input(auth, username, password, message):
response=auth.login(username, password)
assert message in response.data
ログインの検証では、ログイン後のセッションにuser_idを保持しているかどうかを検証している。
with client
:
明示的にリクエストコンテキストを生成するための処理
with client: # ここでリクエストコンテキストを明示的に作成
client.get('/') # 新しいリクエストを送信
assert session['user_id'] == 1 # セッションにアクセス
assert g.user['username'] == 'test' # グローバル変数にアクセス
リクエストコンテキストは通常responseを生成したあとは削除されるため、
この方法でsession,gオブジェクトにアクセスをしている
def test_logout(client,auth):
auth.login()
with client:
auth.logout()
assert 'user_id' not in session
ログアウトした後、sessionの中にuser_idを含んでいないことを確認している。
Blog
import pytest
from flaskr.db import get_db
def test_index(client, auth):
# clientフィクスチャから、flaskへGET request
response = client.get('/')
# responseの中にはhtml情報もあるので表示内容を検証
assert b"Log In" in response.data
assert b"Register" in response.data
# conftest.pyのAuthActionフィクスチャで定義した情報でログインを実行
auth.login()
response = client.get('/')
assert b'Log Out' in response.data
assert b'test title' in response.data
assert b'by test on 2018-01-01' in response.data
assert b'test\nbody' in response.data
assert b'href="/1/update"' in response.data
@pytest.mark.parametrize('path',(
'/create',
'/1/update',
'/1/delete',
))
def test_login_required(client, path):
response = client.post(path)
assert response.headers["Location"] == "/auth/login"
各ページがloginしていないと、ログイン画面に飛ばされることを確認
def test_author_required(app, client, auth):
# change the post author to another user
with app.app_context():
db = get_db()
db.execute('UPDATE post SET author_id=2 WHERE id=1')
db.commit()
auth.login()
# current user can't modify other user's post
assert client.post('/1/update').status_code == 403
assert client.post('/1/delete').status_code == 403
# current user doesn't see edit link
assert b'href="/1/update"' not in client.get('/').data
clientフィクスチャのuser_idは1なので、user_id = 2のユーザが所有しているid=1の投稿内容の更新画面や削除画面にアクセスできない403エラーが出る
なぜwith句がいるのか :
データベースへの接続するためのconnectionオブジェクトはアプリケーションコンテキストで行う必要がある。(その他current_app、g、url_forが対象)
@pytest.mark.parametrize('path'.(
'/2/update',
'/2/delete',
))
def test_exists_required(client, auth, path):
auth.login()
assert client.post(path).status_code == 404
def test_create(client, auth, app):
auth.login()
assert client.get('/create').status_code == 200
client.post('/create', data={'title':'created', 'body':''})
with app.app_context():
db = get_db()
count = db.execute('SELECT COUNT(id) FROM post').fetchone()[0]
assert count == 2
def test_update(client, auth, app):
auth.login()
assert client.get('/1/update').status_code == 200
client.post('/1/update', data={'title':'updated', 'body':''})
with app.app_context():
db = get_db()
post = db.execute('SELECT * FROM post WHERE id=1').fetchone()
assert post['title'] =='updated'
@pytest.mark.parametrize('path',(
'/create',
'/1/update'
))
def test_create_update_validate(client, auth, path):
auth.login()
response = client.post(path, data={'title':'', 'body':''})
assert b'Title is required' in response.data
テストを実行しよう
テストの実行時間を冗長にしないために以下のコードを追記する
[tool.pytest.ini_options]
testpaths = ["tests"]
[tool.coverage.run]
branch = true
source = ["flaskr"]
pytest
実行場所は、flaskr, testsフォルダのある場所で実行(下のflask-tutorial)
flask-tutorial/
├── flaskr/
├── tests/
│ └── conftest.py
└── pyproject.toml
いくつかのHTMLの齟齬や、SQL文のタイポがあったので逐次修正してやっと完了
coverageテストを使ってみる
テストがソースコードのどの部分を実行したか、または実行しなかったかを表示するもの。
テストがどの程度網羅されているかを評価できる。
1 - テストを実行
coverage run -m pytest
2 - HTMLレポートを作成
coverage html
3 - 同じフォルダの中にhtmlcovディレクトリができて、その中にindex.htmlファイルがある。
本番環境に実装
Tutorialには明確なサーバーやソフトウェアについてというより、サーバーへインストールさせるまでの概要を説明されていた。
ハンズオン内容としては、開発環境のPCの中にサーバーを立てることを説明している
開発環境をサーバーにしてネットに公開するのはセキュリティ的に大問題です
Wheelファイル(.whl)
wheelファイルを使えば、アプリケーションをどこでも実装できる。
build
ツールを使って wheelファイルを作成する。
pip install build
python -m -build --wheel
カレントディレクトリにフォルダとファイルが追加される
dist/flaskr-1.0.0-py2.py3-none-any.whl
このファイルをコピーして他のサーバーに配置、新しい仮想環境(virtualenv)を設定して、
pipコマンドで以下の様にインストールを実行する。
pip install flaskr-1.0.0-py3-none-any.whl
次に対象となるマシンのフォルダにデータベース用のinstanceフォルダを構築するためにDB初期化を実行
flask --app flaskr init-db
Secret keyの本番化
Secretキーは開発環境だと "dev"を利用していたが、これはハッカーたちの標的です。
pythonのコマンドを使ってランダムなsecret_keyを作成します、
$ python -c 'import secrets; print(secrets.token_hex())'
'192b9bdd22ab9ed4d12e236c78afcb9a393ec15f71bbf5dc987d54727823bcbf'
他にも本番用の設定があるならここで調整を行う。
本番サーバーでの実行
flask runで建てられるwsgiサーバーは、werkzeugによって設計された開発者向けの便利なサーバーである。しかし、安定性 / 効率性 / セキュリティの面を考慮されているわけではない。
チュートリアルでは 本番用wsgiサーバーとしてwaitress
を紹介している。
これはWindowsとLinuxの両方に対応しているWSGIサーバーとのことだ。
waitressをインストール後は、waitressで作ったwsgiサーバーに対して、
アプリケーションのプロジェクト情報を与える
$ waitress-serve --call 'flaskr:create_app'
Serving on http://0.0.0.0:8080
今後のステップ
1 - ブログの見直し!!
QuickStartのブログから4回にわたってFlaskの公式ドキュメントを解説(翻訳?)したので「信頼できる」知識を学んできました。これからはブログをまず見直してみて、次のポートフォリオに着手していきたい。
2 - 次のポートフォリオに着手
次はSlackやSalesforceの様なツールとの連携のできるアプリを実装して、新しい仕事にチャレンジできる様に力を蓄えていきたい。githubやdockerといった開発用のツールも少し触りたい。
3 - ネイティブアプリ(Flutter)へチャレンジ
実は最終目標は起業なので、ネイティブアプリが視野に入ってます。WEBアプリの勉強はサービス系の基礎になるのでこのまま頑張る。
将来は海外に向けた人道的かつビジネスになるサービスを立てていきたいです。
個人か会社でそんな企画を成功させるのが夢です。残りの人生を誰に捧げたいか見極めよう
FYI : 公式のすゝめ
公式は以下のステップを推奨している。
- 単一の投稿を表示する詳細ビュー。投稿のタイトルをクリックすると、そのページに移動します。
- 投稿に「いいね」や「いいね解除」をする機能。
- コメント。
- タグ。タグをクリックすると、そのタグが付いたすべての投稿が表示されます。
- 名前でインデックスページをフィルタリングする検索ボックス。
- ページ表示。1ページに5件の投稿のみ表示。
- 投稿に画像を添付する機能。
- Markdownを使って投稿をフォーマット。
- 新しい投稿のRSSフィード。
参照 :