tachitomonn’s blog

IT技術関連の学習メモがメインでたまに趣味のこととか

Python Django Tutorial 学習メモ その1

インストール

python バージョンは 3.7.2
インストールは venv の仮想環境で pip で入れました。

>pip install Django

確認してみる。

>>> import django
>>> print(django.get_version())
3.2.6

プロジェクト作成

プロジェクトを置きたいディレクトリにて

>django-admin startproject study_django

study_djangoディレクトリができる。中身は

>dir .\study_django

manage.py ファイルと study_django ディレクト

>dir .\study_django\study_django

以下のファイルが含まれる。

  • asgi.py
  • settings.py
  • urls.py
  • wsgi.py
  • __init__.py

開発用サーバの起動

>cd .\study_django
>python manage.py runserver

ブラウザで http://127.0.0.1:8000/ にアクセスする。

アプリケーションを作る

プロジェクトは、特定のウェブサイトの構成とアプリのコレクションであり、
プロジェクトには複数のアプリを含めることができる。
アプリは複数のプロジェクトに存在できる。

>python manage.py startapp myapp00

myapp00ディレクトリができる。

>dir .\myapp00
  • admin.py
  • apps.py
  • migrations
  • models.py
  • tests.py
  • views.py
  • __init__.py

ビューを書く

\myapp00\views.py にお馴染み Hello, world を書く

from django.http import HttpResponse

def index(request):
    return HttpResponse("Hello, world.")

ビューと URL を紐づける
\myapp00\urls.py

from django.urls import path

from . import views

urlpatterns = [
    path('', views.index, name='index'),
]

ルートの URLconf に myapp00.urls モジュールの記述を反映させる。
study_django/urls.py に django.urls.include の import を追加して、 urlpatterns のリストに include() を挿入する。
\study_django\urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path('myapp00/', include('myapp00.urls')),
    path('admin/', admin.site.urls),
]

include() 関数は他の URLconf への参照をすることができる。
動作確認してみる。

>python manage.py runserver

http://localhost:8000/myapp00/ へアクセスして Hello, world. が表示された。
path() 関数は4つの引数を受け取る。
引数のうち route と view の2つは必須で、kwargs、name の2つは省略可能。
path() 引数: route
route は URL パターンを含む文字列。
リクエストを処理するとき、Django は urlpatterns のはじめのパターンから開始し、リストを順に下に見ていく。
path() 引数: view
Django がマッチする正規表現を見つけると、 Django は指定されたビュー関数を呼び出す。
その際は HttpRequest オブジェクトを第一引数に、そしてキーワード引数として route から「キャプチャされた」値を呼びだす。
path() 引数: kwargs
任意のキーワード引数を辞書として対象のビューに渡すことができる。
path() 引数: name
URL に名前付けをしておけば Django のどこからでも明確に参照でき、とくにテンプレートの中で有効となる。

Python のパッケージ作成 setup.py のお勉強

Python のパッケージングについて、Pythonプロフェッショナルプログラミング 第3版を教科書にして勉強したメモ。
以前作った読書ログアプリケーションをパッケージングしてみました。

Python Flask Tutorial で読書ログアプリーケーションを作ってみた 前編 - tachitomonn’s blog
Python Flask Tutorial で読書ログアプリーケーションを作ってみた 後編 - tachitomonn’s blog


setup.py

パッケージ情報を設定し、パッケージを定義するために使用される。
まず、動作する最小限のsetup.pyを作成する。

from setuptools import setup
setup(name="readinglog")

これで、setupスクリプトのコマンドが実行できる。
最も基本となるソースパッケージを作成する。

> python setup.py sdist

distディレクトリが作成されて中にreadinglog-0.0.0.tar.gzができるがsetup.pyしか含まれていません。

pythonプロジェクトの一般的なディレクトリ構成

パッケージ対象となるファイルが.pyファイル1つだけの場合

プロジェクトディレクトリの配下に

  • LICENSE.txt
  • MANIFEST.in
  • README.rst
  • packagename.py
  • setup.py
パッケージ対象がディレクトリ以下に複数の.pyファイルやテンプレートファイルを持つ場合

プロジェクトディレクトリの配下に

  • LICENSE.txt
  • MANIFEST.in
  • README.rst
  • packagename/
    • __init__.py
    • module.py
    • templates/
      • index.html
  • setup.py

今回は後者のディレクトリ構成をサンプルにします。

setup.pyとMANIFEST.in

setup.pyでパッケージの情報を設定してMANIFEST.inで同梱するファイルを指定する。

setup.py
from setuptools import setup, find_packages

setup(
    name='hoge', #パッケージ名(プロジェクト名と一緒にするのが一般的)
    version='1.0.0', #バージョン番号
    packages=find_packages(), #同梱するPythonパッケージをすべて指定する find_package()は現在のディレクトリ以下のPythonパッケージを自動的に探してパッケージ名をすべて返す(単一の.pyファイルのみの構成の場合はpackagesの代わりにpy_modules引数に対象モジュールを指定する
    include_package_data=True, #packagesで指定したPythonパッケージにある.py以外のファイル(パッケージリソース)をインストールするか指定
    install_requires=[
        'Flask', #依存パッケージをリストで指定
    ],
)
MANIFEST.in

パッケージリソースを同梱する場合はこのファイルにパッケージング対象のファイルを指定する。

recursive-include packagename *.html *.css #指定ディレクトリ以下の指定したパターンに一致するファイルをすべて同梱

アプリケーションに使用しないファイルも指定してパッケージに同梱できる。
LICENSE.txtを同梱するなら、

include LICENSE.txt #指定したパターンに一致するファイルをすべて同梱
動作確認

パッケージを開発するためのvenv環境を作成してインストールしてみる。
プロジェクトディレクトリ配下にvenv環境のディレクトリを作成。
作成したvenv環境をactivateとしてインストール(-eオプションで)。

>pip install -e .

pip freezeでインストールされたか確認する。

setup.py における実行コマンドの作成

ユーザーが実行するコマンドを用意しておくと起動手順などがわかりやすくなる。
setup.pyにentry_pointsを追加してコマンドが自動的に作られるように設定する。
setup()の引数に以下を追加。

 entry_points = {
            "console_scripts":[
                "readinglog = readinglog:main",
                ],
        },

projectdir/readinglog/__init__.py の main 関数を以下のように実装。

def main():
    os.environ["FLASK_APP"] = "readinglog"
    os.environ["FLASK_ENV"] = "development"
    subprocess.call("flask run")

再度インストールを実行してコマンドが作られるか確認する。

python setup.py sdist でソース配布パッケージを作る

配布用パッケージを作成するために

>python setup.py sdist

コマンドを実行。
distディレクトリに作成されるファイルをインストールしたい環境にコピーすれば

>pip install 作成したファイル名

コマンドでファイルからインストールできる。

リポジトリにコミットする

現在のディレクトリ構成を確認する。
Pythonプロジェクトの場合、一般的にリポジトリの最上位ディレクトリにsetup.pyを置くように構成する。

リポジトリに保存する必要のないファイル

リポジトリに保存するファイルをコミットする。
リポジトリ管理不要なファイルは .gitignore ファイルに指定する。
今回はこんな感じ。

dist
instance
*.egg-info
venv
build

.gitignore ファイルもコミットしたらリポジトリサーバにプッシュ。

README.rst で開発環境セットアップ手順を記述

>git clone https://github.com/アカウント/リポジトリ名
>cd アプリケーション名
>python -m venv venv
>venv/Scripts/activate
(venv)>pip install .
(venv)>アプリケーションの実行コマンド

上記手順をそのまま書く。
PythonプロジェクトではreST記法でREADME.rstを書くのが一般的。
README.rstファイルをリポジトリに追加してコミットする。

requirements.txtで開発バージョンを固定する

パッケージを公開する必要がなく、プロジェクト自体もパッケージ化が不要であればsetup.pyも不要。
setup.pyが不要なプロジェクトではrequirements.txtを使う方が効率的。

(venv)>pip freeze > requirements.txt

python setup.py bdist_wheel でwheel配布パッケージを作る

wheelパッケージを作るにはまずwheelをインストールする。

(venv)>pip install wheel

bdist_wheelコマンドが使えるようになる。

(venv)>python setup.py bdist_wheel

でwheelパッケージを作成。
distディレクトリに.whlファイルが作成される。
今回は以下ファイルが作成された。

readinglog-1.0.0-py3-none-any.whl

このファイルをインストールする環境にコピーして

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

でファイルから直接インストールできる。
setup.pyは実行されないためソースパッケージに比べて高速にインストールできる。

Python でパッケージ管理 pip メモ

pip はパッケージをインストールするコマンド。
デフォルトではPyPIからパッケージを探してインストールするが、ローカルのパッケージファイルからでもインストールできる。

よく使いそうなサブコマンド

  • install:パッケージのインストール
  • uninstall:パッケージのアンインストール
  • freeze:インストール済みのパッケージとそのバージョン一覧を requirements フォーマットで出力
  • list:インストール済みのパッケージの一覧を表示
  • show:インストール済みのパッケージの情報を表示
  • search:キーワード指定で PyPI にあるパッケージを検索して一覧表示
  • wheel:指定した requirements からwheelファイルをビルド
  • help:ヘルプ表示

一度に多くのパッケージをインストールするには対象パッケージとそのバージョンを記載した requirements フォーマットのファイルを利用すると便利。
ファイル名は慣例でrequirements.txtという名前にすることが多い。
pip freeze で出力するのが一般的。
requirements.txt ファイルは -r オプションで指定してインストールする。

pip install -r requirements.txt

インストール済のパッケージを最新のバージョンに更新するには-Uオプションを使用する。

pip install -U requests

Linux の基本備忘録

インフラ専門の人間ではないので Linux を操作する頻度はそう多くない。
なので基本の知識はあるけど作業内容によってはちょいちょい調べながらになりがち。
いまいち身についてないけど、これぐらいはさらっとできるようになってないとなと思う備忘録。
ディストリビューションはほぼ ubuntu しか使う機会が今のところないので ubuntu 想定です。

シェルのコマンドラインの編集

カーソル移動
  • Ctrl+b:後方1文字分
  • Ctrl+f:前方1文字分
  • Ctrl+a:行頭
  • Ctrl+e:行末
単語単位でのカーソル移動
  • Alt+b:後方単語1つ分
  • Alt+f:前方単語1つ分
文字の削除
  • Ctrl+w:後方へスペース区切りで1単語分
カットとヤンク
  • Ctrl+k:カーソル位置から行末まで削除
  • Ctrl+u:カーソル位置から行頭まで削除
  • Ctrl+y:最後に削除した内容を挿入

ファイルとディレクト

カレントディレクトリ表示

pwd

ホームディレクトリへの移動

引数なしでcdコマンド

深いディレクトリを作成

mkdir -p

ディレクトリの削除

rm -r

ディレクトリのコピー

cp -r

Vim

単語単位でのカーソル移動
  • w:前方単語1つ分
  • b:後方単語1つ分
  • W:スペース区切りで前方単語1つ分
  • B:スペース区切りで後方単語1つ分
行頭・行末への移動
  • 0:行頭
  • $:行末
行番号指定での移動
  • gg:1行目
  • G:最後の行
  • [数字]G:[数字]行目
デリート(dをyに変えればヤンク)
  • d$:行末まで
  • d0:行頭まで
  • dw:単語1つ
  • dgg:最初の行まで
  • dG:最後の行まで
アンドゥとリドゥ
  • u:直前の編集操作を取り消し(アンドゥ)
  • Ctrl+r:アンドゥの取り消し(リドゥ)
置換
  • %s/[置換対象文字列]/[置換後文字列]/g

ジョブ

  • コマンドの一時停止Ctrl+z
  • ジョブをフォアグラウンドへ fg %<ジョブ番号>
  • ジョブをバックグラウンドへ bg %<ジョブ番号>
  • フォアグラウンドジョブの終了Ctrl+c
  • バックグラウンドジョブの終了kill %<ジョブ番号>

リダイレクト

テキスト処理

sortコマンド
  • -k n n番目のフィールドでソート
  • -n 数値順でソート
  • -r 逆順にソート
uniqコマンド

連続した同じ内容の行を取り除く

tailコマンド

末尾部分を表示する

  • -n n 末尾n行を表示
  • -f 追記されるたびにその内容を表示

grepコマンド

  • -n 行番号付きで表示
  • -i 大文字小文字を区別しない
  • -v マッチしない行を表示

正規表現

シングルクォートで囲むこと

メタ文字

  • . 任意の1文字
  • [] []の中に含まれる、いずれかの1文字
  • [^] []の中に含まれない、いずれかの1文字
  • ^ 行頭を意味
  • $ 行末を意味
  • * 直前の正規表現の0回以上の繰り返し
  • \{m,n\} m回以上n回以下の繰り返し
  • \{m\} ちょうどm回の繰り返し
  • \{m,\} m回以上の繰り返し

アーカイブと圧縮

tarコマンド

アーカイブファイルの作成
tar cf <アーカイブファイル(慣習で .tar 拡張子にする> <アーカイブ元ファイルパス>

アーカイブファイルの内容確認
tar tf <アーカイブファイル>

アーカイブの展開
tar xf <アーカイブファイル> 

gzipコマンド

ファイルの圧縮
gzip <圧縮元ファイル名>
 
圧縮ファイルの展開
gzip -d <圧縮ファイル>

tarとgzipの組み合わせ

tarコマンドだけでtar+gzファイルを作る
cオプションでのアーカイブ作成時にzオプションを追加する
tar czf <アーカイブファイル 慣習で .tar.gz 拡張子にする> <アーカイブ元ファイルパス>

tarコマンドだけでtar+gzファイルを展開する
xオプションでのアーカイブ展開時にzオプションを追加する
tar xzf <アーカイブファイル>

bzip2コマンド

gzipより圧縮率が高いが、時間はかかる
コマンドの使い方はgzipと同じ\
tarと組み合わせるときはjオプション

zipコマンド

アーカイブと圧縮を同時に行う

zipとunzipパッケージのインストール
sudo apt-get install zip unzip

zipファイルの作成
zip -r <圧縮ファイル名> <圧縮対象パス>

zipファイルの内容確認
zipinfo <圧縮ファイル名>

zipファイルの展開
unzip <圧縮ファイル名>

aptによるパッケージ管理

パッケージのインストール

sudo apt-get install <パッケージ名>

パッケージの削除

sudo apt-get remove <パッケージ名>

パッケージの設定ファイルも含めた完全削除

sudo apt-get purge <パッケージ名>

パッケージの検索

apt-cache search <検索ワード>
※ パッケージ名だけを検索対象にするときは --names-only オプション

パッケージの情報表示

apt-cache show <パッケージ名>

Python の requestsモジュール

例によって公式ドキュメントでお勉強。というかほぼ写経なり。

Quickstart — Requests 2.0.0 documentation

人間のためのHTTPライブラリという触れ込みなので標準のurllib2より簡潔に処理を書くことができます。

インストールは pip で入れました。

リクエストをつくる

インポートして、

>>> import requests

ウェブページにGETリクエストします。

>>> r = requests.get('https://github.com/timeline.json')

rにはResponse objectが返ってくる。
シンプルななAPIなので、例えばPOSTリクエストも、

>>> r = requests.post("http://exsample.com/post")

他のHTTPリクエストタイプもシンプルです。

>>> r = requests.put("http://exsample.com/put")
>>> r = requests.delete("http://exsample.com/delete")
>>> r = requests.head("http://exsample.com/get")
>>> r = requests.options("http://exsample.com/get")

URLにおけるパラメータの受け渡し

paramsキーワード引数を使い辞書の形で渡せます。

>>> payload = {'key1': 'value1', 'key2': 'value2'}
>>> r = requests.get("http://exsample.com/get", params=payload)

URLを表示してみる。

>>> print r.url
http://exsample.com/get?key2=value2&key1=value

レスポンスの中身

>>> import requests
>>> r = requests.get('https://github.com/timeline.json')
>>> r.text
u'[{"repository":{"open_issues":0,"url":"https://github.com/...

Requestsではレスポンスの内容を自動的にデコードしてくれる。
r.encodingプロパティで使われたエンコードの確認や変更が可能。

>>> r.encoding
'utf-8'
>>> r.encoding = 'ISO-8859-1'

バイナリ形式のレスポンスの中身

バイト列としてアクセスする。

>>> r.content
b'[{"repository":{"open_issues":0,"url":"https://github.com/...

例えばバイナリデータから画像をつくるコードは、

>>> from PIL import Image
>>> from StringIO import StringIO
>>> i = Image.open(StringIO(r.content))

JSON 形式のレスポンスの中身

>>> import requests
>>> r = requests.get('https://github.com/timeline.json')
>>> r.json()
[{u'repository': {u'open_issues': 0, u'url': 'https://github.com/...

Raw 形式のレスポンスの中身

>>> r = requests.get('https://github.com/timeline.json', stream=True)
>>> r.raw
<requests.packages.urllib3.response.HTTPResponse object at 0x101194810>
>>> r.raw.read(10)
'\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03'

ヘッダのカスタマイズ

辞書の形式でheadersキーワード引数に渡す。

>>> import json
>>> url = 'https://api.github.com/some/endpoint'
>>> payload = {'some': 'data'}
>>> headers = {'content-type': 'application/json'}
>>> r = requests.post(url, data=json.dumps(payload), headers=headers)

より複雑なPOSTリクエス

HTMLのフォームにデータを送りたいような場合、シンプルにdataキーワード引数に辞書を渡すことができる。

>>> payload = {'key1': 'value1', 'key2': 'value2'}
>>> r = requests.post("http://exsample.com/post", data=payload)
>>> print r.text
{
  ...
  "form": {
    "key2": "value2",
    "key1": "value1"
  },
  ...
}

form-encodedしたくない場合、辞書の代わりに string で渡せばデータを直接POSTできる。

>>> import json
>>> url = 'https://api.github.com/some/endpoint'
>>> payload = {'some': 'data'}
>>> r = requests.post(url, data=json.dumps(payload))

Multipart-EncodedファイルのPOST

シンプルにアップロードする。

>>> url = 'http://exsample.com/post'
>>> files = {'file': open('report.xls', 'rb')}
>>> r = requests.post(url, files=files)
>>> r.text
{
 ...
  "files": {
    "file": "<censored...binary...data>"
  },
  ...
}

ファイル名を明示する。

>>> url = 'http://exsample.com/post'
>>> files = {'file': ('report.xls', open('report.xls', 'rb'))}
>>> r = requests.post(url, files=files)
>>> r.text
{
  ...
  "files": {
    "file": "<censored...binary...data>"
  },
  ...
}

レスポンスのステータスコード

ステータスコードを確認する。

>>> r = requests.get('http://exsample.com/get')
>>> r.status_code
200

レスポンスのヘッダー

辞書の形で確認できる。

>>> r.headers
{
    'content-encoding': 'gzip',
    'transfer-encoding': 'chunked',
    'connection': 'close',
    'server': 'nginx/1.0.4',
    'x-runtime': '148ms',
    'etag': '"e1ca502697e5c9317743dc078f67693f"',
    'content-type': 'application/json'
}

クッキー

レスポンスがクッキーを含む場合、アクセスできる。

>>> url = 'http://example.com/some/cookie/setting/url'
>>> r = requests.get(url)
>>> r.cookies['example_cookie_name']
'example_cookie_value'

サーバーにクッキーを送る。

>>> url = 'http://example.com/cookies'
>>> cookies = dict(cookies_are='working')
>>> r = requests.get(url, cookies=cookies)
>>> r.text
'{"cookies": {"cookies_are": "working"}}'

Python Flask Tutorial で読書ログアプリーケーションを作ってみた 後編

Blueprints と Views

Blueprints をつくる

Blueprints は関連した Views や他のコードのまとまりを整理するやり方で、 Views や他のコードを直接アプリケーションに紐づけるのではなく、 Blueprints としてまとめる。そして Blueprints をアプリケーションに紐づける。今回は2つの Blueprints (認証機能と読書ログのポスト機能)をつくる。それぞれの Blueprints は別個のモジュールにつくります。まずは認証から。

readinglog/auth.py

#! /usr/bin/env python
# -*- coding: utf8 -*-

import functools

from flask import (
        Blueprint, flash, g, redirect, render_template, request, session, url_for
        )
from werkzeug.security import check_password_hash, generate_password_hash
from readinglog.db import get_db

bp = Blueprint("auth", __name__, url_prefix="/auth")

authという名前の Blueprint を作成している。アプリケーションオブジェクトと同様に Blueprint は自身がどこに定義されているかを第二引数に渡す。 url_prefix キーワード引数を使い Blueprint に紐づくURLにプレフィックスを追加できる。

application factory からの Blueprint のインポートと紐づけは app.register_blueprint() を使う。 application factory のアプリケーションを返す前にコードを追加します。

readinglog/__init__.py

    from . import auth
    app.register_blueprint(auth.bp)
    return app

auth Blueprint には新規ユーザー登録とログイン/ログアウトのビューを実装していきます。

新規ユーザー登録ビュー

/auth/register という URL に対してのビューとなります。

readinglog/auth.py

@bp.route("/register", methods=("GET", "POST"))
def register():
    if request.method == "POST":
        username = request.form["username"]
        password = request.form["password"]
        db = get_db()
        error = None
        if not username:
            error = "ユーザー名が必要です"
        elif not password:
            error = "パスワードが必要です"
        elif db.execute(
                "SELECT id FROM user WHERE username = ?", (username,)
                ).fetchone() is not None:
            error = "ユーザー名 {} はすでに登録されています".format(username)
        if error is None:
            db.execute(
                    "INSERT INTO user (username, password) VALUES (?, ?)",
                    (username, generate_password_hash(password))
                    )
            db.commit()
            return redirect(url_for("auth.login"))
        flash(error) #エラーメッセージを保持
    return render_template("auth/register.html")

ログインビュー

readinglog/auth.py

@bp.route("/login", methods=("GET", "POST"))
def login():
    if request.method == "POST":
        username = request.form["username"]
        password = request.form["password"]
        db = get_db()
        error = None
        user = db.execute(
                "SELECT * FROM user WHERE username = ?", (username,)
                ).fetchone()
        if user is None:
            error = "ユーザー名が不正です"
        elif not check_password_hash(user["password"], password):
            error = "パスワードが不正です"
        if error is None:
            session.clear()
            session["user_id"] = user["id"]
            return redirect(url_for("index"))
        flash(error)
    return render_template("auth/login.html")

ユーザーIDをセッションに登録することで以降のリクエストでログイン状態に応じて利用できるようにしています。

readinglog/auth.py

@bp.before_app_request
def load_logged_in_user():
    user_id = session.get("user_id")
    if user_id is None:
        g.user = None
    else:
        g.user = get_db().execute(
                "SELECT * FROM user WHERE id = ?", (user_id,)
                ).fetchone()

bp.before_app_request() でどのURLリクエストに関わらずビューの呼び出し前に動く関数を作ることができます。

ログアウトビュー

ユーザーIDをセッションから消去します。

readinglog/auth.py

@bp.route("/logout")
def logout():
    session.clear()
    return redirect(url_for("index"))

他のビューにおける認証の必要性

読書ログの登録、変更、削除といった機能はユーザーがログインしている必要があるのでデコレーターを使うことでこれをチェックします。

readinglog/auth.py

def login_required(view):
    @functools.wraps(view)
    def wrapped_view(**kwargs):
        if g.user is None:
            return redirect(url_for("auth.login"))
        return view(**kwargs)
    return wrapped_view

このデコレーターは元のビューをラップした新しいビューを返す。新しいビューはログイン状態をチェックしてログインしていなければログインページへリダイレクトする。ログインしていれば元のビューが呼ばれて処理は継続される。

テンプレート

テンプレートファイルはパッケージ内の templates ディレクトリに保存する。
Flask ではテンプレートのレンダリングに Jinja テンプレートライブラリを使う。
テンプレートの HTML 中で {{ と }} で囲まれた箇所に python のコードを書けば保持している値に変換されて出力される。
また {% と %} で if 文や for 文のような制御表現も可能。

ベースレイアウト

アプリケーション中のページの共通部分をベーステンプレートにまとめ、それぞれのテンプレートエンジンはベーステンプレートを継承して異なる部分を上書きすることができるので便利。

readinglog/templates/base.html

<!doctype html>
<title>{% block title %}{% endblock %} - 読書ログ</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<nav>
	<h1>読書ログ</h1>
	<ul>
		{% if g.user %}
		   <li><span>{{ g.user['username'] }}</span>
		   <li><a href="{{ url_for('auth.logout') }}">ログアウト</a>
		{% else %}
		   <li><a href="{{ url_for('auth.register') }}">ユーザー登録</a>
		   <li><a href="{{ url_for('auth.login') }}">ログイン</a>
		{% endif %}
	</ul>
</nav>
<section class="content">
	<header>
		{% block header %}{% endblock %}
	</header>
	{% for message in get_flashed_messages() %}
	  <div class="flash">{{ message }}</div>
	{% endfor %}
	{% block content %}{% endblock %}
</section>

g オブジェクトはテンプレート中で自動的に利用できる。url_for() 関数も自動的に利用できる。
get_flashed_messages() 関数はビュー中で flush() 関数で示したエラーメッセージをループで返してくれる。
3つのブロック表現がありそれぞれ他のテンプレートで上書きされることになる。

  • {% block title %} はブラウザのタブとウィンドウのタイトルで表現されるタイトルを変化させる。
  • {% block header %} はtitle と似ているがページで表現されるタイトルを変化させる。
  • {% block content %} はそれぞれのページのコンテンツの位置。

ベーステンプレートは templates ディレクトリに直接置き、 Blueprint で使用されるテンプレートは Blueprint と同じ名前のディレクトリを作りその中に置く。

新規ユーザー登録用テンプレート

readinglog/templates/auth/register.html

{% extends 'base.html' %}

{% block header %}
  <h1>{% block title %}ユーザー登録{% endblock %}</h1>
{% endblock %}

{% block content %}
  <form method="post">
	  <label for="username">ユーザー名</label>
	  <input name="username" id="username" required>
	  <label for="password">パスワード</label>
	  <input type="password" name="password" id="password" required>
	  <input type="submit" value="新規登録">
  </form>
{% endblock %}

{% extends 'base.html' %} でベーステンプレートからブロック部分を置換することを表現できる。
{% block title %} を {% block header %} 中に書くことでタイトルブロックをセットすると同時にヘッダブロック中にその値をセットできる。そうするとウィンドウとページで同じタイトルを2回書かずに表現できる。

ログイン用テンプレート

タイトルとサブミットボタンを除き登録テンプレートと同じ。

readinglog/templates/auth/login.html

{% extends 'base.html' %}

{% block header %}
  <h1>{% block title %}ログイン{% endblock %}</h1>
{% endblock %}

{% block content %}
  <form method="post">
	  <label for="username">ユーザー名</label>
	  <input name="username" id="username" required>
	  <label for="password">パスワード</label>
	  <input type="password" name="password" id="password" required>
	  <input type="submit" value="ログイン">
  </form>
{% endblock %}

静的ファイル

Flask では静的ファイルを置くディレクトリへのパスを返してくれる static ビューが自動的に利用できる。
CSSJavaScript やロゴイメージなど静的ファイルは readinglog/static ディレクトリに置くことで url_for('static', filename='...') のように参照できる。
今回はチュートリアルCSS をそのまま利用させていただきました。

Bookshelf Blueprint

Blueprint を定義して application factory に登録

readinglog/book_shelf.py

#! /usr/bin/env python
# -*- coding: utf8 -*-

from flask import (
        Blueprint, flash, g, redirect, render_template, request, url_for
        )
from werkzeug.exceptions import abort

from readinglog.auth import login_required
from readinglog.db import get_db

import datetime

import requests

bp = Blueprint("book_shelf", __name__)

app.register_blueprint() で登録する。

readinglog/__init__.py

    from . import book_shelf
    app.register_blueprint(book_shelf.bp)
    app.add_url_rule("/", endpoint="index")
    return app

auth Blueprint と異なり url_prefix を持たないので index ビューは / create ビューは /create のような URL になる。
Bookshelf Blueprint はメイン機能なのでインデックスはメインインデックスと同義にする。
しかし index ビューは book_shelf.index にように定義することになる。
app.add_url_rule() を使って index というエンドポイント名を / に紐づけて url_for('index') も url_for('blog.index') も同じく / URL を生成するようにする。

Index

readinglog/book_shelf.py

def get_book_info(posts):
    isbn_lis = [str(post["isbn"]) for post in posts]
    isbns = ",".join(isbn_lis)
    url = "https://api.openbd.jp/v1/get"
    params = {"isbn":isbns}
    r = requests.get(url, params=params)
    json_data = r.json()
    book_info = {}
    for book_data in json_data:
        summary = book_data["summary"]
        book_info[int(summary["isbn"])] = summary
    return book_info

status_dic = {
        0:"未読",
        1:"読書中",
        2:"読了",
        }

evaluation_dic = {
        None:"なし",
        1:"☆",
        2:"☆☆",
        3:"☆☆☆",
        }

@bp.route("/")
@login_required
def index():
    db = get_db()
    user_id = g.user["id"]
    query = """
    SELECT 
    b.id,b.owner_id, b.created, b.isbn, b.status, b.evaluation, 
    b.finished, u.username 
    FROM book_shelf b INNER JOIN user u ON b.owner_id = u.id 
    WHERE b.owner_id = {} 
    ORDER BY b.created DESC
    """.format(user_id)
    posts = db.execute(query).fetchall()
    if posts:
        book_info = get_book_info(posts)
    else:
        book_info = {}
    return render_template("bookshelf/index.html", posts=posts, book_info=book_info, status_dic=status_dic, evaluation_dic=evaluation_dic)

readinglog/template/bookshelf/index.html

{% extends "base.html" %}

{% block header %}
  <h1>{% block title %}最新の本棚{% endblock %}</h1>
  {% if g.user %}
  <a class='action' href='{{ url_for("book_shelf.add") }}'>新規登録 </a>
  {% endif %}
{% endblock %}

{% block content %}
  {% for post in posts %}
    <article class='post'>
	  <header>
	    <div>
			<h1>{{ book_info[post["isbn"]]["title"] }}</h1>
			<div><img src='{{ book_info[post["isbn"]]["cover"] }}' width='10%' height='10%'></div>
			<div class='about'>ISBN {{ post["isbn"] }} {{ book_info[post["isbn"]]["publisher"] }} {{ book_info[post["isbn"]]["author"] }}</div>
			<div class='about'>{{ post["username"] }} が {{ post["created"].strftime("%Y-%m-%d") }} に追加</div>
	    </div>
		{% if g.user["id"] == post["owner_id"] %}
		<a class='action' href='{{ url_for("book_shelf.update", id=post["id"]) }}'>更新</a>
		{% endif %}
	  </header>
	  <p class='body'>ステータス:{{ status_dic[post["status"]] }} 評価:{{ evaluation_dic[post["evaluation"]] }}</p>
    </article>
	{% if not loop.last %}
	  <hr>
	{% endif %}
  {% endfor %}
{% endblock %}

loop.last は Jinja for loops 中で使える特殊な変数。

Create

readinglog/book_shelf.py

@bp.route("/add", methods=("GET", "POST"))
@login_required
def add():
    if request.method == "POST":
        isbn = request.form["isbn"]
        error = None
        if not isbn:
            error = "ISBNコードが必要です"
        if error is not None:
            flash(error)
        else:
            db = get_db()
            query = """
            INSERT INTO book_shelf (owner_id, isbn) 
            VALUES ({}, {})
            """.format(g.user["id"], isbn)
            db.execute(query)
            db.commit()
            return redirect(url_for("book_shelf.index"))
    return render_template("bookshelf/add.html")

readinglog/template/bookshelf/add.html

{% extends "base.html" %}

{% block header %}
	<script type=text/javascript src='{{url_for("static", filename="jquery-3.5.1.min.js")}}'></script>
	<script type=text/javascript src='{{url_for("static", filename="get_book.js")}}'></script>
  <h1>{% block title %}新規登録{% endblock %}</h1>
{% endblock %}

{% block content %}
  <form method='post'>
    <label for='isbn'>ISBNコード</label>
	<input name='isbn' id='isbn' value='{{ request.form["isbn"] }}' required>
	<input type='submit' value='登録'>
  </form>
{% endblock %}

Update

まずは post されたコンテンツを取得するビューを書く。

readinglog/book_shelf.py

def get_post(id, check_owner=True):
    query = """
    SELECT b.id, b.owner_id, b.created, b.isbn, b.status, b.evaluation,
    b.finished, u.username 
    FROM book_shelf b INNER JOIN user u ON b.owner_id = u.id 
    WHERE b.id = {0}
    """.format(id)
    post = get_db().execute(query).fetchone()
    if post is None:
        abort(404, "指定の id {0} は存在しません".format(id))
    if check_owner and post["owner_id"] != g.user["id"]:
        abort(403)
    return post

abort() 関数は HTTP ステータスコードを返す例外を送出する。

readinglog/book_shelf.py

@bp.route("/<int:id>/update", methods=("GET", "POST"))
@login_required
def update(id):
    post = get_post(id)
    if request.method == "POST":
        status = request.form["status"]
        evaluation = request.form["evaluation"]
        if status == 2:
            finished = datetime.datetime.now().strftime("'%Y-%m-%d %H:%M:%S'")
        else:
            finished = "NULL"
        db = get_db()
        query = """
        UPDATE book_shelf SET 
        status = {}, evaluation = {}, finished = {} 
        WHERE id = {}
        """.format(status, evaluation, finished, id)
        db.execute(query)
        db.commit()
        return redirect(url_for("book_shelf.index"))
    book_info = get_book_info([post])
    return render_template("bookshelf/update.html", post=post, book_info=book_info, status_dic=status_dic, evaluation_dic=evaluation_dic)

readinglog/template/bookshelf/update.html

{% extends "base.html" %}

{% block header %}
  <h1>{% block title %}「{{ book_info[post["isbn"]]["title"] }}」{% endblock %}</h1>
{% endblock %}

{% block content %}
	  <div><img src='{{ book_info[post["isbn"]]["cover"] }}' width='10%' height='10%'></div>
  <form method='post'>
	<label for='isbn'>ISBN:{{ post["isbn"] }}</label>
	<label for='created'>登録日時:{{ post["created"] }}</label>
    <label for='status'>ステータス</label>
	<select name='status'>
		{% for i in range(0, 3) %}
		<option value='{{ i }}' {% if i == post["status"] %} selected {% endif %}>{{ status_dic[i] }}</option>
	    {% endfor %}
	</select>
    <label for='evaluation'>評価</label>
	<select name='evaluation'>
		{% for i in range(0, 4) %}
		<option value='{{ i }}' {% if i == post["evaluation"] %} selected {% endif %}>{{ evaluation_dic[i] }}</option>
	    {% endfor %}
	</select>
    <input type='submit' value='更新'>
  </form>
  <hr>
  <form action='{{ url_for('book_shelf.delete', id=post['id']) }}' method='post'>
    <input class='danger' type='submit' value='削除' onclick='return confirm("削除してよろしいですか?");'>
  </form>
{% endblock %}

request はテンプレート中で自動的に使える変数。

Delete

readinglog/book_shelf.py

@bp.route("/<int:id>/delete", methods=("POST",))
@login_required
def delete(id):
    get_post(id)
    db = get_db()
    query = """
    DELETE FROM book_shelf WHERE id= {0}
    """.format(id)
    db.execute(query)
    db.commit()
    return redirect(url_for("book_shelf.index"))

Python Flask Tutorial で読書ログアプリーケーションを作ってみた 前編

公式チュートリアルを写経に利用しつつ、ただの写経はつまらないので読書ログアプリケーションを作ってみた。

Tutorial — Flask Documentation (1.1.x)

プロジェクトのレイアウト

プロジェクト用のディレクトリを作成したら、その中に venv の仮想環境用ディレクトリを作成します。
作成した仮想環境に Flask をインストールする。プロジェクト用のディレクトリには以下が含まれることになる。

  • アプリケーションのコード、ファイルを含むアプリケーション本体のパッケージディレクト
  • テスト用モジュールを含むディレクト
  • Flask とその他プロジェクトに必要なモジュールやパッケージをインストールする仮想環境ディレクト
  • プロジェクト配布用の setup.py
  • git のようなバージョンコントロール用の設定ファイル

バージョンコントロールの対象外にすべきファイル群は無視するように設定します( git なら.gitignore に記述する)

アプリケーションのセットアップ

Flask のアプリケーションは Flask クラスのインスタンスでした。
Flask のアプリケーションの最も単純なものはコードのトップレベルのグローバルな Flask インスタンスをつくったもの。
でもプロジェクトが大きくなると管理が煩雑になるのでグローバルな Flask インスタンスの代わりに関数の中でインスタンスを生成するようにします。
この関数は application factory として知られている。
プロジェクト用のディレクトリ中にアプリケーション本体のパッケージ用ディレクトリをつくり、 __init__.py を作成して入れておきます。__init__.py には application factory を含むこととディレクトリをパッケージとして扱わせる役割がある。

__init__.py

#! /usr/bin/env python
# -*- conding: utf8 -*-

import os

from flask import Flask

def create_app(test_config=None):
    # アプリケーションを生成&デフォルト設定
    app = Flask(__name__, instance_relative_config=True) #設定ファイルはパッケージ外のインスタンスフォルダにあることを明示
    # デフォルト設定
    app.config.from_mapping(
            SECRET_KEY = "dev",
            DATABASE = os.path.join(app.instance_path, "readinglog.sqlite"),
            )
    if test_config is None:
        # テストでなく、設定が存在すればインスタンス設定の読み込み
        app.config.from_pyfile("config.py", silent=True)
    else:
        # テスト設定が渡された場合はテスト設定の読み込み
        app.config.from_mapping(test_config)
    # インスタンスフォルダの存在チェック(自動的には作られない)
    try:
        os.makedirs(app.instance_path)
    except OSError:
        pass
    # Hello, World!
    @app.route("/hello")
    def hello():
        return "Hello World!"
    return app

ここまででアプリケーションを動かしてみます。

> set FLASK_APP=readinglog
> set FLASK_ENV=development
> flask run

http://127.0.0.1:5000/hello にブラウザでアクセスして Hello World! が表示されれば OK!

データベースの定義とアクセス

アプリケーションのユーザーとポストされたデータの保存には SQLite を使います。 SQLite はお手軽だけどある程度の規模のアプリケーションを作るなら MySQL とか別の DB を使おうね。
パッケージ内に db.py をつくります。

db.py

#! /usr/bin/env python
# -*- coding: utf8 -*-

import sqlite3

import click
from flask import current_app, g
from flask.cli import with_appcontext

def get_db():
    if "db" not in g:
        g.db = sqlite3.connect(
                current_app.config["DATABASE"],
                detect_types = sqlite3.PARSE_DECLTYPES
                )
        g.db.row_factory = sqlite3.Row
    return g.db

def close_db(e=None):
    db = g.pop("db", None)
    if db is not None:
        db.close()

g はリクエスト毎にユニークな特殊なオブジェクトです。同一のリクエスト内で二回目の get_db が呼ばれても新たなコネクションは作らず保存されたコネクションが再利用されるようにしている。
current_app も特殊なオブジェクトでリクエストをハンドリングする Flask アプリケーションを示している。
続いてアプリケーションに必要なテーブルを作成するクエリを記述する schema.sql をパッケージ内につくります。

schema.sql

DROP TABLE IF EXISTS user;
DROP TABLE IF EXISTS book_shelf;

CREATE TABLE user (
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	username TEXT UNIQUE NOT NULL,
	password TEXT NOT NULL
);

CREATE TABLE book_shelf (
	id INTEGER PRIMARY KEY AUTOINCREMENT,
	owner_id INTEGER NOT NULL,
	created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
	isbn BIGINT(13) NOT NULL,
	status INTEGER NOT NULL DEFAULT 0,
	evaluation INTEGER DEFAULT NULL,
	finished TIMESTAMP DEFAULT NULL,
	FOREIGN KEY (owner_id) REFERENCES user (id)
);

さらに db.py にテーブル作成クエリを発行する関数を追加します。

db.py

def init_db():
    db = get_db()
    with current_app.open_resource("schema.sql") as f:
        db.executescript(f.read().decode("utf8"))

@click.command("init-db")
@with_appcontext
def init_db_command():
    """Clear the existing data and create new tables."""
    init_db()
    click.echo("Initialized the database.")

open_resource() でアプリケーションパッケージに関連するファイルを開く(ファイルのパスを把握していなくても良いので楽)。click.command() は init-db というコマンドをコマンドラインの有効なコマンドとして定義している。

続いて close_db と init_db_command 関数をアプリケーションのインスタンスと紐づけていきます。
db.py に init_app 関数を実装します。

db.py

def init_app(app):
    app.teardown_appcontext(close_db)
    app.cli.add_command(init_db_command)

app.teardown_appcontext() はレスポンスを返した後に呼ばれる関数を登録することができる。
app.cli.add_command() は新規の flask コマンドとして追加することができる。

init_app 関数を application factory から呼ぶようにします。
__init__.py の app を返す直前に追加します。

__init__.py

    from . import db
    db.init_app(app)
    return app

init_db がアプリケーションに登録されたので flask コマンドとして呼んでみます。

 > flask init-db

プロジェクトディレクトリ内の instance ディレクトリ内に readinglog.sqlite ファイルが生成されるはず。