0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flask - 個人的NOTE / 公式DOC>Tutorial (インストール対応~本番実装) 3/3

Last updated at Posted at 2024-05-25

アプリをインストールに対応させる

インストール対応

インストール対応のメリット

  • どのディレクトリからも実行できる
  • バージョン管理システムで依存関係を管理できる
  • テストツールで、開発環境とテスト環境を区分できる

プロジェクト環境の記載

pyproject.tomlファイルを生成

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 .により開発モードでパッケージをインストールしました。

メリット :

  1. 開発モードでのインストール:

    • ローカルの変更が即座に反映される
    • メタデータの変更(依存関係が変更)のみ再インストールが必要
  2. シンボリックリンク:

    • パッケージのディレクトリがシンボリックリンクされるため、インストール後もソースコードを直接変更できます

シンボリックリンク :
ファイルシステムにおける環境変数の様なもの。特定のPATHを別のPATHとして紐付けをさせて保存できる。

UNIXにおけるシンボリックリンクの作成方法
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_から始まる関数などを用いて検証をしていく。

テスト用のデータを生成

tests/data.sql
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つの処理が備わっている

confest.py
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フィクスチャ

confest.pyの一部
    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 :
      • 現在のセッションオブジェクト

yieldという概念 :

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

client fixture
@pytest.fixture
def client(app):
    return app.test_client()

サーバーを動かさずに、クライアント(Requestを送る人)を生み出している。

Runner fixture

runner fixture
@pytest.fixture
def runner(app):
    return app.test_cli_runner()

アプリケーションに対して、クリックイベントを送るランナーを生成している。

pytestについて

pytestはfixtureで定義した関数名と、テスト関数の引数名を紐づけている。例えば、テスト関数 test_hello() 関数は、clientという引数を持つとする。

pytestでは、fixtureにおける client関数を呼び出し、テスト関数にその戻り値を引き渡す。

Factoryのテスト

Factoryメソッドは他のほとんどのコードで使われているので、もしもエラーがあれば報告されるはずなので、注視してテストをすることはない。

test_factory.py
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 関数からは同じ接続オブジェクトを返し、アプリが閉じられるとその接続も閉じられるべきである。

tests/test_db.py
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は閉じられる。

tests
# データベースを初期化するコマンドの実行を確認している。
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に送信する方法が取られる。この機能を持ったクラスを作成し、フィクスチャを使ってクライアント側に毎回引き渡す。

conftest.py
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インスタンスが入る。

test_auth.py / REGISTERの検証
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) として取得ができる

tests/test_auth.py : LOGINの検証
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オブジェクトにアクセスをしている

LOGOUTの検証
def test_logout(client,auth):
    auth.login()

    with client:
        auth.logout()
        assert 'user_id' not in session

ログアウトした後、sessionの中にuser_idを含んでいないことを確認している。

Blog

test_blog.py:indexのテスト
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
    
test_blog.py: ログインが必要なページのテスト
@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していないと、ログイン画面に飛ばされることを確認

test_blog.py:投稿の権限のテスト
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が対象)

test_blog.py:存在しない投稿への処理のテスト
@pytest.mark.parametrize('path'.(
    '/2/update',
    '/2/delete',
))
def test_exists_required(client, auth, path):
    auth.login()
    assert client.post(path).status_code == 404

test_blog.py:投稿作成のテスト
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
test_blog.py:投稿更新のテスト
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'
test_blog.py:投稿の条件が守られるかテスト
@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文のタイポがあったので逐次修正してやっと完了
image.png

coverageテストを使ってみる
テストがソースコードのどの部分を実行したか、または実行しなかったかを表示するもの。
テストがどの程度網羅されているかを評価できる。

1 - テストを実行
coverage run -m pytest
2 - HTMLレポートを作成
coverage html
3 - 同じフォルダの中にhtmlcovディレクトリができて、その中にindex.htmlファイルがある。

coverage/index.html
image.png

本番環境に実装

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フィード。

参照 :

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?