/var/log/messages

Jul 16, 2018 - 17 minute read - Comments - Python

Flask Tutorial

Python プロフェッショナルプログラミング第 3 版 にて flask 使って云々、みたいな件があり、これは面白い道具だな、ということで以下を見てみました。

SSL 化がまだだな (余計なお世話

What’s in the Box? という節にて以下な記載があります。

  • built-in development server and debugger
  • integrated unit testing support
  • RESTful request dispatching
  • uses Jinja2 templating
  • support for secure cookies (client side sessions)
  • 100% WSGI 1.0 compliant
  • Unicode based
  • extensively documented

ドキュメントを確認してみます。

Tutorial

ありました。以下です。確認しつつ、控えをとりつつ云々してみます。

Project Layout

とりあえずディレクトリを掘って

$ mkdir flask-tutorial
$ cd flask-tutorial
$ python3 -m venv venv
$ . venv/bin/activate

しておいて flask を導入しときます。

$ pip install flask

あと .gitignore も追加して次へ。

Application Setup

app.instance_path って何だろ、って思っていたのですがアプリケーション実行してみると create_app に記述されている通り、instance というディレクトリができています。あるいは test_config というのも謎ですがドキュメントによれば factory から渡される、とありますね。

とりあえずハロワの動作は確認。

Define and Access the Database

順番として

  • 接続、遮断な手続きの定義を flaskr/db.py にて定義 (get_db と close_db)
  • テーブル定義な SQL を flaskr/schema.sql として投入
  • flaskr/db.py に init_db と init_db_command (これ init-db というコマンドになる模様) を追加
  • flaskr/db.py に init_app を追加 (遮断手続きと init_db_command のコマンドの登録を行なっている模様)
  • flaskr/__init__.py の create_app 手続きにて init_app 呼び出しを追加

これで init-db ってコマンドが使える状態になるのかどうか。で、このコマンドからは

  • db.py の init_db が呼び出されて get_db した後に schema.sql な SQL が実行される

のか。

Blueprints and Views

  • Blueprint は controller てきなナニなのかどうか (routing?
  • 直接 SQL 発行している

色々面白いのですが次にすすめてみます。

Templates

View Template は flaskr/templates 配下に、らしい。というか、ログインしたら落ちたぞ。って思ったらまだ index がないのか。

Static Files

style.css 追加。url_for で参照できる模様。スデに base.html には参照なナニがあるのか。

Blog Blueprint

認証なナニができたので、なのか。ここで index 作成らしい。次の節が面白そうなのでここはコード追加して次にすすめます。

とは言え、とりあえずここで動作確認はできるのかな。

Make the Project Installable

ここ、機械翻訳してみます。

プロジェクトをインストール可能にすると、プロジェクト環境にFlaskをインストールしたのと同じように、配布ファイルをビルドして別の環境にインストールすることができます。 これにより、他のライブラリをインストールするのと同じようにプロジェクトをデプロイするので、すべての標準Pythonツールを使用してすべてを管理しています。

インストールには、チュートリアルや新しいPythonユーザーからは分かりにくい他の利点もあります。

  • 現在、PythonとFlaskは、プロジェクトのディレクトリから実行しているため、フラスコパッケージの使用方法のみを理解しています。 インストールとは、どこから実行してもインポートできます。
  • 他のパッケージのように、プロジェクトの依存関係を管理することができます。そのため、プロジェクトをインストールすると、プロジェクトがインストールされます。
  • テストツールは、開発環境からテスト環境を分離することができます。

注 これはチュートリアルの後半で紹介されていますが、今後のプロジェクトでは常にこれから始めるべきです。

Describe the Project

The setup.py file describes your project and the files that belong to it.

setup.py

from setuptools import find_packages, setup

setup(
    name='flaskr',
    version='1.0.0',
    packages=find_packages(),
    include_package_data=True,
    zip_safe=False,
    install_requires=[
        'flask',
    ],
)

パッケージは、Pythonにどのパッケージディレクトリ(およびそれに含まれるPythonファイル)を含めるかを指示します。 find_packages()はこれらのディレクトリを自動的に見つけ出しますので、入力する必要はありません。 静的ディレクトリやテンプレートディレクトリなどの他のファイルをインクルードするには、include_package_dataが設定されています。 Pythonはこの他のデータが何であるかを知るためにMANIFEST.inという別のファイルが必要です。

MANIFEST.in

include flaskr/schema.sql
graft flaskr/static
graft flaskr/templates
global-exclude *.pyc

これはPythonに静的およびテンプレートディレクトリとschema.sqlファイルのすべてをコピーするように指示しますが、すべてのバイトコードファイルを除外します。

使用されるファイルとオプションの別の説明については、公式のパッケージガイドを参照してください。

Install the Project

Use pip to install your project in the virtual environment.

pip install -e .

これは、現在のディレクトリでsetup.pyを見つけて編集可能または開発モードでインストールするようにpipに指示します。 編集可能モードとは、ローカルコードを変更するときに、プロジェクトに関するメタデータ(依存関係など)を変更する場合にのみ再インストールする必要があることを意味します。

プロジェクトが pip list でインストールされていることがわかります。

pip list

Package        Version   Location
-------------- --------- ----------------------------------
click          6.7
Flask          1.0
flaskr         1.0.0     /home/user/Projects/flask-tutorial
itsdangerous   0.24
Jinja2         2.10
MarkupSafe     1.0
pip            9.0.3
setuptools     39.0.1
Werkzeug       0.14.1
wheel          0.30.0

これまでのところあなたのプロジェクトをどのように実行していたかは変わりません。 FLASK_APPは依然としてflaskrに設定されており、フラスコを実行するとアプリケーションが実行されます。

Test Coverage

アプリケーションの単体テストを書くことで、あなたが書いたコードが期待どおりに機能しているかどうかを確認することができます。 Flaskは、アプリケーションへの要求をシミュレートし、応答データを返すテストクライアントを提供します。

できるだけ多くのコードをテストする必要があります。 関数内のコードは、関数が呼び出されたときにのみ実行され、ブロックなどの分岐内のコードは、条件が満たされたときにのみ実行されます。 各機能が各ブランチをカバーするデータでテストされていることを確認する必要があります。

100%カバレッジに近づくほど、変更を加えることで他の動作が予期せず変化することはありません。 ただし、カバレッジが100%であっても、アプリケーションにバグがないことは保証されません。 特に、ユーザーがブラウザでアプリケーションとやりとりする方法をテストしません。 それにもかかわらず、テストカバレッジは開発中に使用する重要なツールです。

注 これはチュートリアルの後半で紹介されていますが、今後のプロジェクトでは開発段階でテストする必要があります。

pytestとカバレッジを使用してコードをテストし、測定します。 両方をインストールします。

pip install pytest coverage

Setup and Fixtures

テストコードはtestsディレクトリにあります。 このディレクトリはフラスコパッケージの隣にあり、その内側にはありません。 tests/conftest.pyファイルには、各テストで使用されるフィクスチャと呼ばれるセットアップ関数が含まれています。 テストはtest_で始まるPythonモジュール内にあり、それらのモジュール内の各テスト関数もtest_で始まります。

各テストでは新しい一時データベースファイルが作成され、テストで使用されるいくつかのデータが読み込まれます。 そのデータを挿入するSQLファイルを作成します。

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');

app fixtureは factory を呼び出し、test_configを渡して、ローカル開発構成を使用する代わりにテスト用にアプリケーションとデータベースを構成します。

tests/conftest.py

import os
import tempfile

import pytest
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()

    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()

tempfile.mkstemp()は一時ファイルを作成して開き、ファイルオブジェクトとそのパスを返します。 DATABASEパスはオーバーライドされるため、インスタンス・フォルダーではなくこの一時パスを指します。パスを設定すると、データベーステーブルが作成され、テストデータが挿入されます。テストが終了したら、一時ファイルを閉じて削除します。

TESTINGは、アプリケーションがテストモードになっていることをFlaskに通知します。 Flaskは内部動作を変更してテストするのが簡単になり、他の拡張機能もフラグを使用して簡単にテストすることができます。

クライアントフィクスチャは、アプリケーションフィクスチャによって作成されたアプリケーションオブジェクトを使用してapp.test_client()を呼び出します。テストでは、クライアントを使用して、サーバーを実行せずにアプリケーションに要求を行います。

ランナフィクスチャはクライアントに似ています。 app.test_cli_runner()は、アプリケーションに登録されたClickコマンドを呼び出すことができるランナーを作成します。

Pytestは、関数名をテスト関数の引数の名前と照合することによってフィクスチャを使用します。たとえば、次に書き込むtest_hello関数は、クライアント引数をとります。 Pytestはそれをクライアントフィクスチャ関数と照合し、それを呼び出して、返された値をテスト関数に渡します。

Factory

Factory 自体についてはあまりテストしません。 ほとんどのコードは既にテストごとに実行されるため、何かが失敗した場合は他のテストに気付くでしょう。

変更できる唯一の動作は、テスト設定を渡すことです。 configが渡されないと、デフォルトの設定があるはずです。そうしないと、設定を上書きする必要があります。

tests/test_factory.py

from flaskr import create_app


def test_config():
    assert not create_app().testing
    assert create_app({'TESTING': True}).testing


def test_hello(client):
    response = client.get('/hello')
    assert response.data == b'Hello, World!'

チュートリアルの初めにファクトリを書くときに、helloルートを例として追加しました。 “Hello、World!”が返されるため、テストでは応答データが一致するかどうかがチェックされます。

Database

アプリケーションコンテキスト内では、get_dbは呼び出されるたびに同じ接続を返さなければなりません。 コンテキストの後、接続を閉じる必要があります。

tests/test_db.py

import sqlite3

import pytest
from flaskr.db import get_db


def test_get_close_db(app):
    with app.app_context():
        db = get_db()
        assert db is get_db()

    with pytest.raises(sqlite3.ProgrammingError) as e:
        db.execute('SELECT 1')

    assert 'closed' in str(e)

init-dbコマンドは、init_db関数を呼び出してメッセージを出力する必要があります。

tests/test_db.py

def test_init_db_command(runner, monkeypatch):
    class Recorder(object):
        called = False

    def fake_init_db():
        Recorder.called = True

    monkeypatch.setattr('flaskr.db.init_db', fake_init_db)
    result = runner.invoke(args=['init-db'])
    assert 'Initialized' in result.output
    assert Recorder.called

このテストでは、Pytestのmonkeypatch fixtureを使用して、init_db関数を呼び出されたことを記録する関数に置き換えます。 上で書いたランナー・フィクスチャは、init-dbコマンドを名前で呼び出すために使用されます。

Authentication

ほとんどのビューでは、ユーザーがログインする必要があります。テストでこれを行う最も簡単な方法は、クライアントとのログインビューへのPOST要求を行うことです。 毎回それを書くのではなく、それを行うメソッドを持つクラスを作成し、フィクスチャを使ってテストごとにクライアントに渡すことができます。

tests/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()を呼び出してテストユーザーとしてログインすることができます。これは、アプリケーションフィクスチャのテストデータの一部として挿入されました。

レジスタビューはGETで正常にレンダリングされるはずです。 有効なフォームデータを持つPOSTでは、ログインURLにリダイレクトされ、ユーザーのデータはデータベースに格納されます。 無効なデータにはエラーメッセージが表示されます。

tests/test_auth.py

import pytest
from flask import g, session
from flaskr.db import get_db


def test_register(client, app):
    assert client.get('/auth/register').status_code == 200
    response = client.post(
        '/auth/register', data={'username': 'a', 'password': 'a'}
    )
    assert 'http://localhost/auth/login' == response.headers['Location']

    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()はGET要求を作成し、Flaskによって返されたResponseオブジェクトを返します。同様に、client.post()はPOST要求を出し、データdictをフォームデータに変換します。

ページが正常にレンダリングされることをテストするために、簡単な要求が行われ、200 OK status_codeがチェックされます。レンダリングに失敗した場合、Flaskは500 Internal Server Errorコードを返します。

ヘッダーには、レジスタビューがログインビューにリダイレクトされるときに、ログインURLを持つLocationヘッダーがあります。

dataには応答の本文がバイトとして格納されます。特定の値がページ上でレンダリングされることを期待する場合は、そのデータがデータに含まれていることを確認します。バイトはバイトと比較される必要があります。 Unicodeテキストを比較する場合は、代わりにget_data(as_text = True)を使用してください。

pytest.mark.parametrizeはPytestに対して異なる引数で同じテスト関数を実行するよう指示します。ここでは、同じコードを3回書き込まずに異なる無効な入力およびエラーメッセージをテストします。

ログインビューのテストは、レジスタのテストと非常によく似ています。データベース内のデータをテストするのではなく、セッションはログイン後にuser_idを設定する必要があります。

tests/test_auth.py

def test_login(client, auth):
    assert client.get('/auth/login').status_code == 200
    response = auth.login()
    assert response.headers['Location'] == 'http://localhost/'

    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

withブロックでクライアントを使用すると、応答が返された後にセッションなどのコンテキスト変数にアクセスできます。 通常、リクエストの外部でセッションにアクセスすると、エラーが発生します。

テストログアウトはログインの反対です。 セッションは、ログアウト後にuser_idを含むべきではありません。

tests/test_auth.py

def test_logout(client, auth):
    auth.login()

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

Blog

すべてのブログビューは、以前に書いたauthフィクスチャを使用します。 auth.login() を呼び出すと、クライアントからの後続の要求がテストユーザーとしてログインします。

インデックスビューには、テストデータとともに追加された投稿に関する情報が表示されます。 著者としてログインすると、投稿を編集するためのリンクが必要です。

また、インデックスビューをテストする際にいくつかの認証動作をテストすることもできます。 ログインしていないときは、各ページにログインまたは登録するためのリンクが表示されます。 ログインすると、ログアウトするためのリンクがあります。

tests/test_blog.py

import pytest
from flaskr.db import get_db


def test_index(client, auth):
    response = client.get('/')
    assert b"Log In" in response.data
    assert b"Register" in response.data

    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

作成、更新、および削除ビューにアクセスするには、ユーザーがログインしている必要があります。 ログインしたユーザーは、更新と削除にアクセスする投稿の作成者でなければなりません。そうでない場合は、403 Forbiddenステータスが返されます。 指定されたIDを持つ投稿が存在しない場合、更新と削除で404が見つかりません。

tests/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'] == 'http://localhost/auth/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


@pytest.mark.parametrize('path', (
    '/2/update',
    '/2/delete',
))
def test_exists_required(client, auth, path):
    auth.login()
    assert client.post(path).status_code == 404

作成ビューと更新ビューは、GET要求の200 OKステータスをレンダリングして返す必要があります。 POSTリクエストで有効なデータが送信されると、createは新しいポストデータをデータベースに挿入し、更新は既存のデータを変更する必要があります。 両方のページに無効なデータが表示されるはずです。

tests/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


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

削除ビューはインデックスURLにリダイレクトされ、その投稿はデータベースに存在しなくなります。

tests/test_blog.py

def test_delete(client, auth, app):
    auth.login()
    response = client.post('/1/delete')
    assert response.headers['Location'] == 'http://localhost/'

    with app.app_context():
        db = get_db()
        post = db.execute('SELECT * FROM post WHERE id = 1').fetchone()
        assert post is None

Running the Tests

必須ではありませんが、カバレッジの少ない実行テストを冗長にしないような余分な設定を、プロジェクトのsetup.cfgファイルに追加することができます。

setup.cfg

[tool:pytest]
testpaths = tests

[coverage:run]
branch = True
source =
    flaskr

テストを実行するには、pytestコマンドを使用します。 あなたが書いたすべてのテスト機能を見つけて実行します。

pytest

========================= test session starts ==========================
platform linux -- Python 3.6.4, pytest-3.5.0, py-1.5.3, pluggy-0.6.0
rootdir: /home/user/Projects/flask-tutorial, inifile: setup.cfg
collected 23 items

tests/test_auth.py ........                                      [ 34%]
tests/test_blog.py ............                                  [ 86%]
tests/test_db.py ..                                              [ 95%]
tests/test_factory.py ..                                         [100%]

====================== 24 passed in 0.64 seconds =======================

テストが失敗すると、pytestは発生したエラーを表示します。 pytest -vを実行すると、ドットではなく各テスト関数のリストを取得できます。

テストのコードカバレッジを測定するには、カバレッジコマンドを使用してpytestを直接実行するのではなく実行します。

coverage run -m pytest

端末で簡単なカバレッジレポートを表示することもできます。

coverage report

Name                 Stmts   Miss Branch BrPart  Cover
------------------------------------------------------
flaskr/__init__.py      21      0      2      0   100%
flaskr/auth.py          54      0     22      0   100%
flaskr/blog.py          54      0     16      0   100%
flaskr/db.py            24      0      4      0   100%
------------------------------------------------------
TOTAL                  153      0     44      0   100%

HTMLレポートを使用すると、各ファイルに含まれる行を確認できます。

coverage html

これにより、htmlcovディレクトリにファイルが生成されます。 ブラウザでhtmlcov / index.htmlを開き、レポートを表示します。

Deploy to Production

チュートリアルのこの部分では、アプリケーションをデプロイするサーバーがあることを前提としています。 配布ファイルを作成してインストールする方法の概要を説明しますが、使用するサーバーまたはソフトウェアの詳細については説明しません。 開発用コンピュータに新しい環境をセットアップして、以下の手順を試すことができますが、実際の公開アプリケーションのホスティングには使用しないでください。 アプリケーションをホストするさまざまな方法のリストについては、「展開オプション」を参照してください。

Build and Install

アプリケーションを他の場所に配置する場合は、配布ファイルを作成します。 現在のPythonディストリビューションの標準は.whl拡張子を持つホイールフォーマットです。 ホイールライブラリが最初にインストールされていることを確認します。

pip install wheel

Pythonでsetup.pyを実行すると、ビルド関連のコマンドを発行するためのコマンドラインツールが提供されます。 bdist_wheelコマンドはホイール配布ファイルを作成します。

python setup.py bdist_wheel

このファイルは dist/flaskr-1.0.0-py3-none-any.whl にあります。ファイル名はプロジェクトの名前、バージョン、ファイルに関するいくつかのタグがインストール可能です。

このファイルを別のマシンにコピーし、新しいvirtualenvを設定してからpipでインストールしてください。

pip install flaskr-1.0.0-py3-none-any.whl

Pipはあなたのプロジェクトをその依存関係とともにインストールします。

これは別のマシンであるため、init-dbを再度実行してインスタンスフォルダにデータベースを作成する必要があります。

export FLASK_APP=flaskr
flask init-db

Flaskがインストールされている(編集可能モードではない)ことを検出すると、インスタンスフォルダに別のディレクトリが使用されます。 代わりに venv/var/flaskr-instance で見つけることができます。

Configure the Secret Key

チュートリアルの初めに、SECRET_KEYのデフォルト値を指定しました。 これは、プロダクションのいくつかのランダムなバイトに変更する必要があります。 それ以外の場合、攻撃者はパブリック ‘dev’キーを使用してセッションCookieを変更するか、秘密鍵を使用するその他のものを使用できます。

次のコマンドを使用して、ランダムな秘密鍵を出力できます。

python -c 'import os; print(os.urandom(16))'

b'_5#y2L"F4Q8z\n\xec]/'

インスタンスフォルダにconfig.pyファイルを作成します。これは、ファクトリが存在する場合はそこから読み込みます。 生成された値をその中にコピーします。

venv/var/flaskr-instance/config.py

SECRET_KEY = b'_5#y2L"F4Q8z\n\xec]/'

SECRET_KEYはFlaskrに必要な唯一のものですが、その他の必要な設定もここで行うことができます。

Run with a Production Server

開発ではなく公開して実行する場合は、組み込み開発サーバー(フラスコ走行)を使用しないでください。 開発サーバーは、便宜上Werkzeugによって提供されていますが、特に効率的、安定的、または安全であるようには設計されていません。

代わりに、運用WSGIサーバーを使用します。 たとえば、Waitressを使用するには、まず仮想環境にインストールします。

pip install waitress

Waitressにあなたのアプリケーションについて話す必要がありますが、フラスコ走行のようにFLASK_APPは使用しません。 アプリケーションオブジェクトを取得するには、アプリケーションファクトリをインポートして呼び出すように指示する必要があります。

waitress-serve --call 'flaskr:create_app'

Serving on http://0.0.0.0:8080

アプリケーションをホストするさまざまな方法のリストについては、「Deploying Option」を参照してください。 ウェイトレスは、WindowsとLinuxの両方をサポートしているため、チュートリアルのために選ばれた単なる例です。 プロジェクトには、さらに多くのWSGIサーバーと展開オプションが用意されています。

Python 仮想環境 機械学習の開発環境

comments powered by Disqus