2024年1月31日水曜日

Python の oepnai-whisper で Speech-to-text したので結果のメモ

Python の oepnai-whisper で Speech-to-text したので結果のメモ

概要

M2 mac mini 上で openapi-whisper を動かして音声解析してみました
音声ファイルが大きい場合は素直に mlx 経由で実行しましょう

環境

  • macOS 14.2.1 (M2 pro mac mini)
  • Python 3.11.6
  • openai-whisper 1.1.10
  • MLX 0.0.10

インストール

  • pipenv install openai-whisper

サンプルコード

import whisper

# モデルは large を使用
model = whisper.load_model("large")
# 解析
result = model.transcribe("/path/to/audio.m4a")
# ファイル書き出し
with open("result.txt", mode="w") as f:
    f.write(result["text"])

使用モデル

  • large
  • ファイルサイズ 2.88G

音声ファイル

  • m4a ファイル
  • サイズは 45.41MB
  • ビットレート 64kbps

解析時間

  • 不明 (かなりかかりそうなので途中でやめて mlx 形式にしました)

MLX 経由で実行する

以下は MLX を使って whisper を動作させる方法です
こちらを参考に設定していきます

clone

  • git clone https://github.com/ml-explore/mlx-examples.git

初期化

  • cd mlx-examples/whisper
  • brew install ffmpeg
  • pipenv install -r ./requirements.txt

モデルのコンバート

pytorch で作成された whisper のモデルを mlx で使用可能なモデルに変換します
もしかすると hugging face の MLX Community を使えば自分でコンバートする必要はないかもです

  • pipenv run python convert.py --torch-name-or-path large --mlx-path mlx_models/large
[INFO] Loading
[INFO] Saving

生成されるファイルは以下のとおりです

tree mlx_models
mlx_models
└── large
    ├── config.json
    └── weights.npz

2 directories, 2 file

スクリプト作成

  • vim app.py
import whisper

# 生成したモデルと音声ファイルを指定して解析
result = whisper.transcribe(
    "/path/to/audio.m4a", path_or_hf_repo="mlx_models/large", verbose=True
)

# ファイル書き出し
with open("result.txt", mode="w") as f:
    f.write(result["text"])

ここで import している whisper.transcribe は mlx-examples が独自で修正している transcribe.py になります
なので path_or_hf_repo というパラメータが指定できます

実行

  • pipenv run python app.py

今回の場合成功すれば result.txt にテキスト情報がそのまま保存されます

解析時間

  • 約1時間

GPU はほぼフルで使っていました
スペックによってはさらに速くなるかなと思います

最後に

mlx + whisper で M2 mac mini 上で音声解析してみました
精度はかなりいいので Google の Speech-to-text レベルのものがローカルで動作するようになります
スペックの低いマシンではかなり時間がかかるのでそれなりのスペックのマシン特に GPU のあるマシンを使って解析することをオススメします

また mlx を使うことで GPU をフルに使って解析するので時間も短縮できます

language='ja' というキーワード引数を指定しなくても自動で日本語だと判断してくれましたが入れてもいいのかもしれません
verbose=True を指定すると自動検出した言語を print デバッグしてくれるので試しに入れてみてもいいのかもしれません
また verbose=True は解析の経過も表示してくれるのでファイルが大きい場合は入れたほうがいいかなと思います

参考サイト

2024年1月30日火曜日

docker のログを logspout で集約する

docker のログを logspout で集約する

概要

logspout は docker のソケットファイルを使ってログを監視し監視しているログを別のログ集約システムに流すことができるツールです
監視するログはルールによって変更することができるため特定のログだけを特定の場所に飛ばしたりすることができます
今回はとりあえず動かすのが目的なので docker のすべてのログを logstash に流してみます

環境

  • macOS 14.2.1
  • docker 24.0.7
  • docker-compose 2.23.3
  • logspout 3.2.14

docker-compose.yml

logger は logstash コンテナで syslog を受取れるようにします
logspout は docker.sock を使い docker ログを監視します
今回の設定はすべてのログ (docker デーモン自体のログ) も監視対象です
web はデバッグ用のアプリになります

version: '3.8'
services:
  logger:
    image: logstash:8.12.0
    volumes:
      - ./logstash:/etc/logstash
    ports:
      - 5000:5000/udp
    environment:
      - XPACK_MONITORING_ENABLED=false
    command: logstash -f /etc/logstash/logstash.conf
  logspout:
    image: gliderlabs/logspout:v3.2.14
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock
    depends_on:
      - logger
    command: syslog://192.168.1.23:5000
  web:
    image: nginx
    ports:
      - "80:80"
    depends_on:
      - logspout

logstash.conf

syslog を受取りそれを標準出力します
ポイントは type = docker にする点です

input {
  syslog {
    port => 5000
    type => "docker"
  }
}

output {
  stdout {}
}

動作確認

  • docker-compose up -d

コンテナを起動しアプリにアクセスします
そして logger のログを見るとちゃんとアプリのログが logspout 経由で飛ばされているのが確認できると思います

  • docker-compose logs -f
  • curl localhost
web-1       | 192.168.65.1 - - [24/Jan/2024:05:53:36 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/8.4.0" "-"
web-1       | 192.168.65.1 - - [24/Jan/2024:05:53:54 +0000] "GET / HTTP/1.1" 200 615 "-" "curl/8.4.0" "-"
logger-1    |        "message" => "<14>1 2024-01-24T05:53:36Z f9940f8079d8 docker-web-1 67205 - - 192.168.65.1 - - [24/Jan/2024:05:53:36 +0000] \"GET / HTTP/1.1\" 200 615 \"-\" \"curl/8.4.0\" \"-\"\n",
logger-1    |         "original" => "<14>1 2024-01-24T05:53:36Z f9940f8079d8 docker-web-1 67205 - - 192.168.65.1 - - [24/Jan/2024:05:53:36 +0000] \"GET / HTTP/1.1\" 200 615 \"-\" \"curl/8.4.0\" \"-\"\n"

特定のログだけフィルタするには

このままだとカーネルログも監視するので logger コンテナのログの量が大変なことになってしまいます
logspout は特定のログだけ監視することができる機能があり例えばコンテナ名で絞り込むには filter.name という機能を使います

logspout の command で filter.name を指定します
ワイルドカードで複数のコンテナ名で絞り込むことも可能です

これでカーネルのログは表示されなくなります

version: '3.8'
services:
  logger:
    image: logstash:8.12.0
    volumes:
      - ./logstash:/etc/logstash
    ports:
      - 5000:5000/udp
    environment:
      - XPACK_MONITORING_ENABLED=false
    command: logstash -f /etc/logstash/logstash.conf
  logspout:
    image: gliderlabs/logspout:v3.2.14
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock
    depends_on:
      - logger
    command: "syslog://192.168.1.23:5000?filter.name=*web*"
  web:
    image: nginx
    ports:
      - "80:80"
    depends_on:
      - logspout

最後に

logspout を試してみました
現状だと docker のロギングドライバで直接 fluentd や logstash に流すことができるのであまり使用する場面は少ないのかもしれません
またここ最近あまりメンテナンスもされていないため使用は控えたほうがいいのかもしれません

参考サイト

2024年1月29日月曜日

docker で gelf ドライバを使って logstash にログを送信してみる

docker で gelf ドライバを使って logstash にログを送信してみる

概要

過去に fluent-driver を使って fluentd にログを送信する方法を紹介しました
今回は gelf driver を使って logstash にログを送信してみました
なおコンテナは docker-compose で動作させます

環境

  • macOS 14.2.1
  • docker 24.0.7
  • docker-compose 2.23.3
  • logstash 8.12.0

docker-compose.yml

logstash と適当なアプリを起動します
ポイントはアプリ側の driver 指定で gelf を指定します
ここが logstash が起動するエンドポイントを指定する部分です

logstash は logger として docker-compose 内で指定します
12201:udp として起動します
logstash.conf はあとで紹介しますが今回は Elasticsearch などには連携せず受け取ったアプリからのログをそのまま標準出力するだけです

  • vim docker-compose.yml
version: '3.8'
services:
  logger:
    image: logstash:8.12.0
    volumes:
      - ./logstash:/etc/logstash
    command: logstash -f /etc/logstash/logstash.conf
    ports:
      - 12201:12201/udp
  web:
    image: nginx
    ports:
      - "80:80"
    logging:
      driver: gelf
      options:
        gelf-address: udp://192.168.1.23:12201
        tag: docker.web
    depends_on:
      - logger

logstash/logstash.conf

12201 で受け取ったログを標準出力します

  • vim logstash/logstash.conf
input {
  gelf {
    port => 12201
  }
}

output {
  stdout {}
}

動作確認

コンテナを起動します

  • docker-compose up -d

あとはログを確認しつつアプリにアクセスします
すると logstash 側に以下のようなログが出力されることが確認できます

  • docker-compose logs -f
  • curl localhost
logger-1  | {
logger-1  |               "host" => "docker-desktop",
logger-1  |           "image_id" => "sha256:6c7be49d2a11cfab9a87362ad27d447b45931e43dfa6919a8e1398ec09c1e353",
logger-1  |              "level" => 6,
logger-1  |            "version" => "1.1",
logger-1  |            "created" => "2024-01-24T00:28:59.739392089Z",
logger-1  |        "source_host" => "192.168.65.1",
logger-1  |           "@version" => "1",
logger-1  |     "container_name" => "docker-web-1",
logger-1  |         "@timestamp" => 2024-01-24T00:29:47.975Z,
logger-1  |         "image_name" => "nginx",
logger-1  |            "message" => "192.168.65.1 - - [24/Jan/2024:00:29:47 +0000] \"GET / HTTP/1.1\" 200 615 \"-\" \"curl/8.4.0\" \"-\"",
logger-1  |                "tag" => "docker.web",
logger-1  |            "command" => "/docker-entrypoint.sh nginx -g daemon off;",
logger-1  |       "container_id" => "2c997b2676e305f5fb7d29e49415c45a5cb9d7edcd6f73ad03d45124d158c246"
logger-1  | }

ElasticSearch に接続できないというエラーがずっと出る

以下のようなエラーがずっとログに出力されていました

logger-1  | [2024-01-24T00:35:42,235][ERROR][logstash.licensechecker.licensereader] Unable to retrieve Elasticsearch cluster info. {:message=>"No Available connections", :exception=>LogStash::Outputs::ElasticSearch::HttpClient::Pool::NoConnectionAvailableError}
logger-1  | [2024-01-24T00:35:42,236][ERROR][logstash.licensechecker.licensereader] Unable to retrieve license information from license server {:message=>"No Available connections"}
logger-1  | [2024-01-24T00:35:42,752][INFO ][logstash.licensechecker.licensereader] Failed to perform request {:message=>"elasticsearch: Name or service not known", :exception=>Manticore::ResolutionFailure, :cause=>#<Java::JavaNet::UnknownHostException: elasticsearch: Name or service not known>}
logger-1  | [2024-01-24T00:35:42,753][WARN ][logstash.licensechecker.licensereader] Attempted to resurrect connection to dead ES instance, but got an error {:url=>"http://elasticsearch:9200/", :exception=>LogStash::Outputs::ElasticSearch::HttpClient::Pool::HostUnreachableError, :message=>"Elasticsearch Unreachable: [http://elasticsearch:9200/
][Manticore::ResolutionFailure] elasticsearch: Name or service not known"}

おそらく logstash の output に ElasticSearch の設定がないからだと思います
今回のようにテスト用途で ES に接続しないのであれば XPACK_MONITORING_ENABLED=false という環境変数を logstash に設定してあげると自動で接続しにいかなくなります

version: '3.8'
services:
  logger:
    image: logstash:8.12.0
    volumes:
      - ./logstash:/etc/logstash
    command: logstash -f /etc/logstash/logstash.conf
    environment:
      - XPACK_MONITORING_ENABLED=false
    ports:
      - 12201:12201/udp
  web:
    image: nginx
    ports:
      - "80:80"
    logging:
      driver: gelf
      options:
        gelf-address: udp://192.168.1.23:12201
        tag: docker.web
    depends_on:
      - logger

最後に

docker + logstash を連携してみました
基本的にはこのあとログを ElasticSearch に格納することになります
fluentd ではなく logstash を使いたい場合には gelf ドライバを使いましょう

参考サイト

2024年1月26日金曜日

iOS12 でネットワーク共有に接続できない場合の対処方法

iOS12 でネットワーク共有に接続できない場合の対処方法

対処方法

設定 -> 一般 -> リセット -> ネットワーク設定をリセット

で再度接続してみてください

環境

  • iPhone6 (12.5.7) 共有される側
  • iPhoneXR (17.12) 共有する側

2024年1月25日木曜日

Keras で mnist のチュートリアルをやってみる

Keras で mnist のチュートリアルをやってみる

概要

これまでは文章を学習させてきました
今回は画像認識を keras を使ってやってみました
コードの解説や疑問点はコメントで記載しています

mnist + Keras のチュートリアルをやってみました
コードはリファクタしていません

環境

  • macOS 14.2.1
  • Python 3.11.6
  • tenforflow 2.15.0
  • keras 2.15.0

サンプルコード

各種何をやっているかの説明はコメントに記載しています

import numpy as np
import tensorflow as tf

mnist = tf.keras.datasets.mnist
# 60000個の学習データ
(x_train, y_train), (x_test, y_test) = mnist.load_data()
# ピクセル情報は0-255の範囲なので255で割ることで0-1の範囲に変換する
x_train, x_test = x_train / 255.0, x_test / 255.0

model = tf.keras.models.Sequential([
    tf.keras.layers.Flatten(input_shape=(28, 28)),  # 1つの画像データが28x28のピクセルデータ
    tf.keras.layers.Dense(128, activation='relu'),  # ここが128なのが不明64や256でも動作はする (ハイパーパラメータか?
    tf.keras.layers.Dropout(0.2),  # ここの0.2なのも不明、そもそもDropout がなくても動作する (ハイパーパラメータか?
    tf.keras.layers.Dense(10)  # 結果は0-9の手書きの数字のどれかなので10出力
])

# 損失関数の作成 (交差エントロピー誤差)
loss_fn = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True)

# モデルコンパイル
model.compile(optimizer="adam",
              loss=loss_fn,
              metrics=["accuracy"])

# 学習
model.fit(x_train, y_train, epochs=5)
# 評価
model.evaluate(x_test, y_test)
# 予測と結果表示
results = model.predict(x_test[:5])
# 結果はDense(10)なので10個の配列の予測値から成り立っている
# その配列10個中で一番値の大きいインデックスが予測した手書きの数字になる
# np.argmax はそれを一発でやってくれる便利メソッド
print(np.argmax(results, axis=1))  # [7 2 1 0 4] になるはず
print(y_test[:5])  # [7 2 1 0 4] こっちは答え

学習データについて

  • 60000 枚の画像のデータが入っている
    • 1枚の画像情報は 28 x 28 のデータで格納されている
    • それぞれの情報は0-255までのピクセル情報が格納されている
  • x_train が画像のピクセルデータで y_train がピクセルデータの実際の答えの数字が格納されている一次元の配列 (当然 60000 個のデータ

学習時間

M2 mac mini の GPU ありででだいたい 5 分程度で完了します

最後に

やはりレイヤーを作る部分が職人技のような気がします

参考サイト

2024年1月24日水曜日

django-rest-framework を試す

django-rest-framework を試す

概要

django で REST API を構築することができるプラグインがあったので試してみました

環境

  • macOS 11.7.10
  • Python 3.11.6
  • django 5.0.1
  • django-rest-framework 3.14.0

インストール

  • pipenv run djangorestframework

プロジェクト作成

  • pipen run django-admin startproject tutorial .
  • cd tutorial
  • django-admin startapp quickstart
  • cd …
tutorial
├── __init__.py
├── asgi.py
├── quickstart
│   ├── __init__.py
│   ├── admin.py
│   ├── apps.py
│   ├── migrations
│   │   └── __init__.py
│   ├── models.py
│   ├── serializers.py
│   ├── tests.py
│   └── views.py
├── settings.py
├── urls.py
└── wsgi.py

マイグレーション

今回はマイグレーションで自動的に作成されるユーザテーブルの情報を使うようです

  • pipenv run python manage.py migrate

ユーザ作成

  • python manage.py createsuperuser --username admin --email admin@example.com

パスワードだけ入力します

シリアライザの作成

データベースのモデルをレスポンスモデルに変換するクラスです

  • vim tutorial/quickstart/serializers.py
from django.contrib.auth.models import Group, User
from rest_framework import serializers


class UserSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = User
        fields = ["url", "username", "email", "groups"]


class GroupSerializer(serializers.HyperlinkedModelSerializer):
    class Meta:
        model = Group
        fields = ["url", "name"]

ビューの作成

先程のシリアライザを指定します
データベースから取得したデータは指定されたシリアライザを使って自動的にレスポンスデータに変換されます

  • vim tutorial/quickstart/views.py
from django.contrib.auth.models import Group, User
from rest_framework import permissions, viewsets

from tutorial.quickstart.serializers import GroupSerializer, UserSerializer


class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all().order_by("-date_joined")
    serializer_class = UserSerializer
    permission_classes = [permissions.IsAuthenticated]


class GroupViewSet(viewsets.ModelViewSet):
    queryset = Group.objects.all()
    serializer_class = GroupSerializer
    permission_classes = [permissions.IsAuthenticated]

URL マッピングの追加

admin 用の画面と実際に REST API をコールするためのマッピングを追加します
REST は DefaultRouter を使って定義します

  • vim tutorial/urls.py
from django.urls import include, path
from rest_framework import routers

from tutorial.quickstart import views

router = routers.DefaultRouter()
router.register(r"users", views.UserViewSet)
router.register(r"groups", views.GroupViewSet)

urlpatterns = [
    path("", include(router.urls)),
    path("api-auth/", include("rest_framework.urls", namespace="rest_framework")),
]

urlpatterns += router.urls

自動ページネイトの追加

SQL から取得したデータが長い場合などに自動でページネイト処理をしてくれるようです

  • vim tutorial/settings.py
REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 10,
}

REST アプリの追加

最後に REST アプリのの設定を追加します

  • vim tutorial/settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "rest_framework",
]

動作確認

  • pipenv run python manage.py runserver

認証あり API なので先程作成したユーザとパスワードを入力しましょう

  • curl -s -u admin -H 'Accept: application/json' http://localhost:8000/users/ | jq .
{
  "count": 1,
  "next": null,
  "previous": null,
  "results": [
    {
      "url": "http://127.0.0.1:8000/users/1/",
      "username": "admin",
      "email": "admin@example.com",
      "groups": []
    }
  ]
}

ブラウザで http://localhost:8000/users/ いアクセスすると管理画面を使えます
右上のログインから admin ユーザでログインすると REST API をコールするクライアントとして使えます

最後に

django-rest-framework を試してみました

触りだけなので何ともですがリクエストモデルやレスポンスモデルはシリアライザを使えばうまく表現できるかもしれません
ビューの queryset などは独特の記法なのでこのあたりは django に慣れている場合は嬉しいかもしれません
URL マッピングなども django の機能をそのまま使っているようです

admin が付属しているのでテストなどはしやすいかもしれません

参考サイト

2024年1月23日火曜日

Pydantic の CustomTypes を使ってみる

Pydantic の CustomTypes を使ってみる

概要

同じような記述のタイプヒントを複数のモデルで使い回す場合には CustomType で独自の型を定義して使いますと便利です

環境

  • macOS 11.7.10
  • Python 3.11.6
  • pydantic 2.5.3

サンプルコード

from typing import Annotated

from pydantic import BaseModel, Field, TypeAdapter

Name = Annotated[str, Field(min_length=5)]


class Animal(BaseModel):
    name: Name


class User(BaseModel):
    name: Name
    age : int


AdaptedName = TypeAdapter(Name)

if __name__ == "__main__":
    user_data = {
        "name": "hawksnowlog",
        "age": 10
    }
    user = User(**user_data)
    print(user.name)
    animal_data = {
        "name": "snowlog",
        # "name": "hawk", # error
    }
    animal = Animal(**animal_data)
    print(animal.name)
    # TypeAdapter を使えばクラスにしないでもカスタムタイプを検証できる
    name= AdaptedName.validate_python("hawksnowlog")
    # name = AdaptedName.validate_python("hawk")  # error
    print(name)

最後に

継承してもいいですがわざわざ継承するほどでもない共通のフィールドを扱いたい場合には便利かなと思います
型を一箇所で管理できれば一箇所修正するだけで複数のモデルに適用することができます

参考サイト

2024年1月22日月曜日

Pydantic の TypeAdapter を使ってみる

Pydantic の TypeAdapter を使ってみる

概要

使ってみました
簡単に言うと独自のクラスを扱うコレクションクラスを新しいクラスとして定義することができる機能です

環境

  • macOS 11.7.10
  • Python 3.11.6
  • pydantic 2.5.3

サンプルコード

from typing import List

from pydantic import BaseModel, TypeAdapter


class User(BaseModel):
    name: str
    age : int


UserList = TypeAdapter(List[User])


if __name__ == "__main__":
    # 単一データの場合 (TypeAdapterなし)
    data = {
        "name": "hawk",
        "age": 10
    }
    user = User(**data)
    print(user.name)
    # 複数データの場合 (TypeAdapter あり)
    data_list = [
        {
            "name": "snowlog",
            "age": 20,
        },
        {
            "name": "hawksnowlog",
            "age": 30,
        }
    ]
    user_list = UserList.validate_python(data_list)
    for user in user_list:
        print(user.name)
    # 複数データの場合 (TypeAdapter なし)
    user_list: list[User]
    for data in data_list:
        user = User(**data)
        user_list.append(user)
    for user in user_list:
        print(user.name)

解説

サンプルコードを見るのが一番理解できるかなと思います
TypeAdapter を使った場合はシリアライズが 1 行で完了しますが使っていない場合はシリアライズをわざわざループで回して行う必要があるので記述が面倒になります

これが TypeAdapter の一番の機能かなと思います

最後に

データベースのモデルなども使うことができるのか気になりました
CustomTypesという機能もありこれと組み合わせると更に便利に使うことができそうです

参考サイト

2024年1月19日金曜日

Django と celery の連携方法

Django と celery の連携方法

概要

Django と Celery を連携してみました
公式のドキュメントだと django 側には celery 連携のドキュメントなく celery 側にあるようです

環境

  • macOS 11.7.10
  • Python 3.11.6
  • Django 5.0.1
  • celery 5.3.6
  • Redis 7.2.1

mysite/settings.py

一部抜粋

# for celery
CELERY_CACHE_BACKEND = "default"
CELERY_BROKER_URL = "redis://localhost:6379"

polls/celery.py

import os

from celery import Celery

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")

app = Celery("polls")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()


@app.task(bind=True, ignore_result=True)
def debug_task(self):
    print(f"Request: {self.request}")

polls/__init__.py

from .celery import app as celery_app

__all__ = ("celery_app",)

ワーカー起動

  • pipenv run celery -A polls worker -l INFO

動作確認

  • pipenv run python manage.py shell
from polls.celery import debug_task
debug_task.apply_async()

最後に

連携自体はそれほど難しくないようです
設定やタスクは celery の公式にあるように記載しましたが別ファイルで管理してもいいかなと重ます

参考サイト

2024年1月18日木曜日

Django で MySQL を使う

Django で MySQL を使う

概要

前回作ったチュートリアルアプリは SQLite で動作していました
今回は MySQL に切り替えてみたいと思います

環境

  • macOS 11.7.10
  • Python 3.11.6
  • Django 5.0.1
  • MySQL 8.1.0

データベース作成

  • create database django_test;

settings.py の編集

ドライバは PyMySQL を使います
公式おすすは mysqlclient のようです
DATABASES の部分のみ紹介します
ユーザ名やホストは適宜変更してください

  • vim mysite/settings.py
DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.mysql",
        "NAME": "django_test",
        "HOST": "localhost",
        "PORT": 3306,
        "USER": "root",
        "PASSWORD": "",
        # "ENGINE": "django.db.backends.sqlite3",
        # "NAME": BASE_DIR / "db.sqlite3",
    }
}

manage.py の編集

main の先頭で install_as_MySQLdb をコールします

  • vim manage.py
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys

import pymysql


def main():
    pymysql.install_as_MySQLdb()
    """Run administrative tasks."""
    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == "__main__":
    main()

マイグレーション

  • pipenv run python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, polls, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying polls.0001_initial... OK
  Applying sessions.0001_initial... OK

動作確認

mysql -u root -p django_test -e "show create table polls_question \G"
Enter password: 
*************************** 1. row ***************************
       Table: polls_question
Create Table: CREATE TABLE `polls_question` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `question_text` varchar(200) NOT NULL,
  `pub_date` datetime(6) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

最後に

切り替え自体は簡単に行なえます
プロダクションでは mysqlclient を使ったほうがいいかなと思います
DATABASES で複数のデータベースを管理するのはどうやればいいのだろうか

参考サイト

2024年1月17日水曜日

Django のチュートリアルをやってみた

Django のチュートリアルをやってみた

概要

実は触っことがなかったのでチュートリアルを消化してみました

簡単な Hello world からモデルの定義、テンプレートの利用など基本的な使い方を紹介しているようです

環境

  • macOS 11.7.10
  • Python 3.11.6
  • Django 5.0.1

インストール

  • pipenv install django

プロジェクト作成

  • pipenv run django-admin startproject mysite
  • cd mysite

以下作業は pipenv + mysite 配下のディレクトリで行っていきます

Hello world

  • pipenv run python manage.py startapp polls

チュートリアルでは投票アプリを作るようなのでそのまま指示に従ってアプリを作成します

Hello world を返却するビューを作成します

  • vim polls/views.py
from django.http import HttpResponse


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

ビューをルーティングに追加します

  • vim polls/urls.py
from django.urls import path

from . import views

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

大本のサイトから投票アプリを参照します

  • vim mysite/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("polls/", include("polls.urls")),
    path("admin/", admin.site.urls),
]

あとはアプリを起動して

  • pipenv run python manage.py runserver
  • curl localhost:8000/polls/

にアクセスして Hello world のテキスト返ってくることを確認します

データベースマイグレート

  • pipenv run python manage.py migrate
Operations to perform:
  Apply all migrations: admin, auth, contenttypes, sessions
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying sessions.0001_initial... OK

デフォルトは SQLite のようです
mysite/settings.py に記載してある INSTALLED_APPS にあるアプリをマイグレートするようです

実際にテーブルの一覧はこんな感じでした

  • sqlite3 db.sqlite3
SQLite version 3.32.3 2020-06-18 14:16:19
Enter ".help" for usage hints.
sqlite> select name from sqlite_master where type = 'table';
django_migrations
sqlite_sequence
auth_group_permissions
auth_user_groups
auth_user_user_permissions
django_admin_log
django_content_type
auth_permission
auth_group
auth_user
django_session

モデルの作成

データベースのモデルを定義します
このあとこれを使って再度マイグレーションしテーブルを作成します

  • vim polls/models.py
import datetime

from django.db import models
from django.utils import timezone


class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField("date published")

    def __str__(self):
        return self.question_text

    def was_published_recently(self):
        return self.pub_date >= timezone.now() - datetime.timedelta(days=1)


class Choice(models.Model):
    question = models.ForeignKey(Question, on_delete=models.CASCADE)
    choice_text = models.CharField(max_length=200)
    votes = models.IntegerField(default=0)

    def __str__(self):
        return self.choice_text

__str__ などのメソッドはあとで追加することになるので先に実装しています

polls の設定を追加します
mysite/settings.py は長いので変更部分のみ記載しています
INSTALLED_APPS に PollsConfig を追加します

  • vim mysite/settings.py
INSTALLED_APPS = [
    "polls.apps.PollsConfig",
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
]

アプリ用のテーブルの作成

  • pipenv run python manage.py makemigrations polls
Migrations for 'polls':
  polls/migrations/0001_initial.py
    - Create model Question
    - Create model Choice

マイグレーションファイルが作成されることを確認します
SQLAlchemy のようにマイグレーションファイルが微妙な場合は自分で修正しましょう

マイグレーションファイルが作成できたら実際にマイグレーションしてみます
sqlmigrate コマンドで事前に実行される SQL を確認することもできます

  • pipenv run python manage.py sqlmigrate polls 0001
  • pipenv run python manage.py migrate

マイグレートコマンドは先程と同じです

Operations to perform:
  Apply all migrations: admin, auth, contenttypes, polls, sessions
Running migrations:
  Applying polls.0001_initial... OK

実際にテーブルを確認すると polls_question と polls_choice テーブルが追加されています

sqlite> .schema polls_question
CREATE TABLE IF NOT EXISTS "polls_question" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "question_text" varchar(200) NOT NULL, "pub_date" datetime NOT NULL);
sqlite> .schema polls_choice
CREATE TABLE IF NOT EXISTS "polls_choice" ("id" integer NOT NULL PRIMARY KEY AUTOINCREMENT, "choice_text" varchar(200) NOT NULL, "votes" integer NOT NULL, "question_id" bigint NOT NULL REFERENCES "polls_question" ("id") DEFERRABLE INITIALLY DEFERRED);
CREATE INDEX "polls_choice_question_id_c5b4b260" ON "polls_choice" ("question_id");

おまけ: 作成したモデルをコンソールから操作する

rails console のような機能があり作成したモデルを操作することができるようなので試してみます

  • pipenv run python manage.py shell

これで作成した Question や Choice モデルが扱えます
操作方法は公式にあるので割愛しますが簡単なデバッグが挙動の確認、テーブル情報の確認をしたいときには便利かなと思います

テスト用のデータなども登録しているので一通り流しておきましょう

from polls.models import Question, Choice
Question.objects.all()
from django.utils import timezone
q = Question(question_text="What's new?", pub_date=timezone.now())
q.save()
q.id
q.question_text
q.pub_date
q.question_text = "What's up?"
q.save()
Question.objects.all()

q = Question.objects.get(pk=1)
q.choice_set.create(choice_text="Not much", votes=0)
q.choice_set.create(choice_text="The sky", votes=0)
c = q.choice_set.create(choice_text="Just hacking again", votes=0)
c = q.choice_set.filter(choice_text__startswith="Just hacking")
c.delete()

admin ツールを使う

django には標準で admin 用のツールがありデータの確認や登録がアプリとは別で行うことができるようになっています

  • pipenv run python manage.py createsuperuser

ユーザ名、パスワード、メールアドレスの入力を求められるので入力しましょう
サーバを起動し localhost:8000/admin/ にアクセスすると管理画面に遷移します

  • pipenv run python manage.py runserver

  • vim polls/admin.py
from django.contrib import admin

from .models import Question

admin.site.register(Question)

これで再度リロードすると polls アプリのオブジェクト (データベース) も扱うことができます

適当に登録、更新、削除など試してみると良いかなと思います
デフォルトで更新履歴も管理してくているようです

ビューを追加する

アプリの機能を追加していきます

  • polls/views.py
from django.http import HttpResponse


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


def detail(request, question_id):
    return HttpResponse(f"You're looking at question {question_id}.")


def results(request, question_id):
    return HttpResponse(f"You're looking at the results of question {question_id}.")


def vote(request, question_id):
    return HttpResponse(f"You're voting on question {question_id}.")
  • polls/urls.py
from django.urls import path

from . import views

app_name = "polls"
urlpatterns = [
    path("", views.index, name="index"),
    path("<int:question_id>/", views.detail, name="detail"),
    path("<int:question_id>/results/", views.results, name="results"),
    path("<int:question_id>/vote/", views.vote, name="vote"),
]

テンプレートの追加

少し独特なパスですが以下のようにすることで自動で読み込んでくれます
html ファイルで構文は j2 です (たぶん)

  • mkdir -p polls/templates/polls/
  • vim polls/templates/polls/index.html

url という特殊なタグが使えるようです

urls.py
の name とリンクしているようです
{% if latest_question_list%}
<ul>
{% for question in latest_question_list %}
  <li><a href='{% url "detail" question.id %}'>{{ question.question_text }}</a>
{% endfor %}
</ul>
{% else %}
<p>No polls are available.</p>
{% endif %}
  • vim polls/templates/polls/detail.html
<h1>{{ question.question_text }}</h1>
<ul>
{% for choice in question.choice_set.all %}
  <li>{{ choice.choice_text }}</li>
{% endfor %}
</ul>

ビュー側です
django.shortcuts モジュールを使うとテンプレートの呼び出しやエラーハンドリングを簡単に書けるようです

  • vim polls/views.py
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render

from .models import Question


def index(request):
    latest_question_list = Question.objects.order_by("-pub_date")[:5]
    context = {
        "latest_question_list": latest_question_list,
    }
    return render(request, "polls/index.html", context)


def detail(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    return render(request, "polls/detail.html", { "question": question })


def results(request, question_id):
    return HttpResponse(f"You're looking at the results of question {question_id}.")


def vote(request, question_id):
    return HttpResponse(f"You're voting on question {question_id}.")

フォームの作成

先程作成したテンプレートを改良して form post できるようにします

  • vim polls/templates/polls/detail.html
<form action="{% url 'polls:vote' question.id %}" method="post">
{% csrf_token %}
<fieldset>
  <legend><h1>{{ question.question_text }}</h1></legend>
  {% if error_message %}<p><strong></strong></p>{% endif %}
  {% for choice in question.choice_set.all %}
    <input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}">
    <label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label>
    <br>
  {% endfor %}
</fieldset>
<input type="submit" value="Vote">
</form>

結果を表示するテンプレートも作成します

  • vim polls/templates/polls/results.html
<h1>{{ question.question_text }}</h1>
<ul>
{% for choice in question.choice_set.all%}
  <li>{{ choice.choice_text }} -> {{ choice.votes }} vote{{ choice.votes | pluralize }}</li>
{% endfor %}
</ul>

<a href="{% url 'polls:detail' question.id %}">Vote again?</a>

POST が受け取れるように vote 関数を変更します
フォームデータには request.POST でアクセスするようです

  • vim polls/views.py
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse

from .models import Question


def index(request):
    latest_question_list = Question.objects.order_by("-pub_date")[:5]
    context = {
        "latest_question_list": latest_question_list,
    }
    return render(request, "polls/index.html", context)


def detail(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    return render(request, "polls/detail.html", {"question": question})


def results(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    return render(request, "polls/results.html", {"question": question})


def vote(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    try:
        selected_choice = question.choice_set.get(pk=request.POST["choice"])
    except (KeyError, Choice.DoesNotExist):
        return render(
            request,
            "polls/detail.html",
            {"question": question, "error_message": "You didn't select a choice"},
        )
    else:
        selected_choice.votes += 1
        selected_choice.save()
        return HttpResponseRedirect(reverse("polls:results", args=(question.id,)))

汎用ビュー化

ビューを少しリファクリングします
今まで関数で定義していたビューをクラス化します
汎用ビューはビューで使用するモデルとテンプレートが決まっている場合に簡潔にビューを定義できる機能です

  • vim polls/views.py
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.views import generic

from .models import Question


class IndexView(generic.ListView):
    template_name = "polls/index.html"
    context_object_name = "latest_question_list"

    def get_queryset(self):
        return Question.objects.order_by("-pub_date")[:5]


class DetailView(generic.DetailView):
    model = Question
    template_name = "polls/detail.html"


class ResultsView(generic.DetailView):
    model = Question
    template_name = "polls/results.html"


def vote(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    try:
        selected_choice = question.choice_set.get(pk=request.POST["choice"])
    except (KeyError, Choice.DoesNotExist):
        return render(
            request,
            "polls/detail.html",
            {"question": question, "error_message": "You didn't select a choice"},
        )
    else:
        selected_choice.votes += 1
        selected_choice.save()
        return HttpResponseRedirect(reverse("polls:results", args=(question.id,)))

URL マッピング側も関数ではなくクラスを参照するように書きえます
汎用ビューの場合には pk というパスパラメータが自動的に使用されるので question_id -> pk に変更します

  • vim polls/urls.py
from django.urls import path

from . import views

app_name = "polls"
urlpatterns = [
    path("", views.IndexView.as_view(), name="index"),
    path("<int:pk>/", views.DetailView.as_view(), name="detail"),
    path("<int:pk>/results/", views.ResultsView.as_view(), name="results"),
    path("<int:question_id>/vote/", views.vote, name="vote"),
]

テストを書く

django.test.TestCase のサブクラスとして書くようです
テストファイルのアプリ配下に書きます

  • vim polls/tests.py
import datetime

from django.test import TestCase
from django.utils import timezone

from .models import Question


class QuestionModelTests(TestCase):
    def test_was_published_recently_with_future_question(self):
        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertIs(future_question.was_published_recently(), False)

    def test_was_published_recently_with_old_question(self):
        time = timezone.now() + datetime.timedelta(days=1, seconds=1)
        old_question = Question(pub_date=time)
        self.assertIs(old_question.was_published_recently(), False)

    def test_was_published_recently_with_recent_question(self):
        time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
        recent_question = Question(pub_date=time)
        self.assertIs(recent_question.was_published_recently(), True)
  • pipenv run python manage.py test polls

テストは失敗します
バグがあるので Question モデルの was_published_recently を修正します

  • vim polls/models.py
import datetime

from django.db import models
from django.utils import timezone


class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField("date published")

    def __str__(self):
        return self.question_text

    def was_published_recently(self):
        now = timezone.now()
        return now - datetime.timedelta(days=1) <= self.pub_date <= now


class Choice(models.Model):
    question = models.ForeignKey(Question, on_delete=models.CASCADE)
    choice_text = models.CharField(max_length=200)
    votes = models.IntegerField(default=0)

    def __str__(self):
        return self.choice_text

これで再度テストを実行すると OK になります

以下続きますが省略します
やってることはそれほど難しくないので興味があれば続けてやってみるよ良いかなと思います

ビューのテスト

テストクライアントがあるのでそれを使います

デザインの追加

css や画像を追加します

Admin のカスタマイズ

リレーション先の参照やフォームのカスタマイズをします

デバッグツール

django-debug-toolbar を使います

自分のアプリのパッケージ化

Pypi で公開できるようにパッケージ化します

感想

  • とにかくドキュメントが充実している
  • Ruby で言うところの rails
  • django が提供しているモデルやツールを使う感じなので DSL という感じが強かった
  • ディレクトリ構成が強制されるのは良いことだと思うが少し変な構造な感じもした (tests や admin など)
  • チュートリアルのコードはコピペで横着せず全部自分でタイプしながらやると力になる
  • admin ツールが標準装備なので便利 (テストデータの登録や確認など)
  • チュートリアルだと pyright のタイプエラーが結構出る
  • pydantic との連携が微妙そう (アダプタ的なのはあるがあまりメンテされてなさそう)

最後に

途中までですが django のチュートリアルを試してみました
まさにフレームワークという感じで書き方や作り方はルールに則って進める必要があります
簡単に構築したい場合にはおすすめなのかもしれませんが如何せんブラックボックスが多いのと独特な機能や記法を使う部分も多いので Python 全般というよりかは django という機能や言語を学んでいるという感覚が強いかなと思います

参考サイト