ラベル flask の投稿を表示しています。 すべての投稿を表示
ラベル flask の投稿を表示しています。 すべての投稿を表示

2026年1月8日木曜日

超簡単 websocket お試しコード

超簡単 websocket お試しコード

概要

二人専用チャットルームを簡単に作成してみます

環境

  • macOS 15.7.2
  • Python 3.12.11
    • Flask==3.1.2
    • Flask-SocketIO==5.6.0
    • eventlet==0.40.4

インストール

  • pyenv local 3.12.11
  • pipenv install flask flask-socketio eventlet

アプリ

  • vim app.py
from typing import cast

from flask import Flask, render_template, request
from flask_socketio import SocketIO, emit, join_room, leave_room

app = Flask(__name__)

socketio = SocketIO(app, cors_allowed_origins="*")

CHAT_ROOM_ID = "hoge"

# 接続管理(sid を保持)
chat_clients: set[str] = set()


@app.route("/<room_id>")
def chat(room_id: str):
    if room_id != CHAT_ROOM_ID:
        return "Not Found", 404
    return render_template("index.html", room_id=CHAT_ROOM_ID)


@socketio.on("connect")
def on_connect():
    sid = cast(str, request.sid)  # type: ignore

    if len(chat_clients) >= 2:
        emit("full", to=sid)
        return False

    chat_clients.add(sid)
    join_room(CHAT_ROOM_ID)

    if len(chat_clients) == 1:
        # 1人目
        emit("status", "待っています…", to=sid)
    else:
        # 2人目が入った瞬間に「全員」に通知
        emit("status", "準備OK!", to=CHAT_ROOM_ID)


@socketio.on("message")
def on_message(data):
    msg = data.get("message")
    if msg:
        emit("message", msg, to=CHAT_ROOM_ID, include_self=False)


@socketio.on("disconnect")
def on_disconnect():
    sid = cast(str, request.sid)  # type: ignore
    chat_clients.discard(sid)
    leave_room(CHAT_ROOM_ID)

    if len(chat_clients) == 1:
        # 残った1人に通知
        emit("status", "終わり", to=CHAT_ROOM_ID)


if __name__ == "__main__":
    socketio.run(app, host="0.0.0.0", port=5000, debug=False)

テンプレート

  • vim templates/index.html
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>Private Chat</title>
    <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"></script>
  </head>
  <body>
    <h3>Private Chat</h3>
    <div id="status"></div>
    <ul id="log"></ul>

    <input id="msg" placeholder="message" />
    <button onclick="send()">Send</button>

    <script>
      const socket = io();

      socket.on("status", (msg) => {
        document.getElementById("status").innerText = msg;
      });

      socket.on("message", (msg) => {
        const li = document.createElement("li");
        li.textContent = msg;
        log.appendChild(li);
      });

      socket.on("full", () => {
        alert("このチャットは満員です");
      });

      function send() {
        const input = document.getElementById("msg");
        socket.emit("message", { message: input.value });
        input.value = "";
      }
    </script>
  </body>
</html>

動作確認

localhost:5000/hoge に複数のブラウザでアクセスしてメッセージのやり取りができることを確認しましょう

できない場合はファイアウォールや NetFilter、リバースプロキシなどの設定を確認しましょう
WebSocket は Upgrade: example/1, foo/2 のような Upgrade ヘッダの許可が必須なので前段でヘッダが弾かれている可能性があります

最後に

5000 番ポートがすでに使われている場合はポートを変更するか AirPlay レシーバをオフにしましょう

lsof -i :5000

COMMAND    PID         USER   FD   TYPE             DEVICE SIZE/OFF NODE NAME
ControlCe 1129       user01   10u  IPv4 0xfb24a12bbd08b2c2      0t0  TCP *:commplex-main (LISTEN)
ControlCe 1129       user01   11u  IPv6 0x9dc35b82883e083d      0t0  TCP *:commplex-main (LISTEN)

2025年3月19日水曜日

flask-sqlalchemy で pyright 用のスタブファイルを作成してみた

flask-sqlalchemy で pyright 用のスタブファイルを作成してみた

概要

flask-sqlalchemy + pyright を使っているとデータベースのモデルからインスタンスを生成する際に pyright のエラーになります
回避方法はいろいろありますが今回は pyright 用のスタブ情報を手動で作成することで対応してみました

環境

  • Python 3.12.9
    • flask-sqlalchemy 3.1.1
    • pyright 1.1.396

エラーの出るファイル

  • vim my_libs/schema.py
from flask_sqlalchemy import SQLAlchemy

db = SQLAlchemy()


class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(80), nullable=False)


# インスタンス作成
user = User(id=1, name="Alice")

これで pyright を実行すると以下のえらーになります
これをスタブを作成することで回避します

  • pipenv run pyright my_libs/schema.py
/path/to/my_libs/schema.py
  /path/to/my_libs/schema.py:12:13 - error: No parameter named "id" (reportCallIssue)
  /path/to/my_libs/schema.py:12:19 - error: No parameter named "name" (reportCallIssue)
2 errors, 0 warnings, 0 informations

スタブファイルの自動生成

  • pipenv run pyright --createstub flask_sqlalchemy

これで typings 配下に以下のファイルが自動で生成されます

ls -ltR typings                                   
total 0
drwxr-xr-x  12 user01  staff  384  3 18 10:22 flask_sqlalchemy

typings/flask_sqlalchemy:
total 120
-rw-r--r--  1 user01  staff    171  3 18 10:22 track_modifications.pyi
-rw-r--r--  1 user01  staff    710  3 18 10:22 table.pyi
-rw-r--r--  1 user01  staff   1428  3 18 10:22 session.pyi
-rw-r--r--  1 user01  staff   1870  3 18 10:22 record_queries.pyi
-rw-r--r--  1 user01  staff   3024  3 18 10:22 query.pyi
-rw-r--r--  1 user01  staff   5279  3 18 10:22 pagination.pyi
-rw-r--r--  1 user01  staff   5297  3 18 10:22 model.pyi
-rw-r--r--  1 user01  staff  14197  3 18 10:22 extension.pyi
-rw-r--r--  1 user01  staff    321  3 18 10:22 cli.pyi
-rw-r--r--  1 user01  staff    180  3 18 10:22 __init__.pyi

動作確認

スタブファイルを作成した状態で再度 pyright を実行しましょう
先のほどまで出ていた No parameter named "name" などのエラーが出ないことが確認できると思います

おまけ: その他の解決方法

__init__ メソッドを User モデルクラスに実装する

この方法でも pyright のエラー自体は出なくなりますがコンストラクタを生成することで初期化時の処理が変わる可能性があるのであまりおすすめはしないです

dict を使い変数展開する

User 生成時にキーワード引数ではなく辞書の展開を使ってもエラーを回避できます

params = {
  "id": 1,
  "name": "hawk"
}
user = User(**params)

最後に

flask-sqlalchemy の pyright エラーに対応してみました
typings/ ディレクトリは今回は自動生成しているので git には含めないほうがいいです
手動で作成している場合は含めたほうがいいです

CI などで typings が必要な場合は毎回生成してあげるようにしましょう

おまけ: スタブファイルの手動作成(失敗編)

以下試したけどダメだったパターンです

スタブファイルはプロジェクトルートの typings というディレクトリ配下に作成します
またパッケージの階層も同じにする必要がありかつファイル名は pyi ファイルにします

  • mkdir typings
  • mkdir typings/my_libs
  • vim typings/my_libs/schema.pyi
from flask_sqlalchemy import SQLAlchemy

db: SQLAlchemy

class User():
    id: int
    name: str

    def __init__(self, id: int, name: str) -> None: ...

2025年3月13日木曜日

OpenTelemetry で celery の非同期タスクでもリクエストと同じトレースをする方法

OpenTelemetry で celery の非同期タスクでもリクエストと同じトレースをする方法

概要

Flask アプリから celery の非同期タスクを呼び出した際にもトレースできるようにします
Flask、Celery で一貫して OLTP にトレース情報を送信するサンプルコードを紹介します

環境

  • macOS 15.3.1
  • Python 3.12.9
    • flask 3.1.0
    • open-telemetry 0.51b0
    • celery 5.4.0
  • OpenTelemetry Collector 0.121.0
  • Jaeger (all in one) 1.67.0

追加インストール

前回のに追加で以下をインストールします

  • pipenv install opentelemetry-instrumentation-celery celery

app.py

from celery import Celery
from flask import Flask
from opentelemetry import trace
from opentelemetry.propagate import inject

from lib.tasks import roll_dice_async

# Flask アプリの作成
app = Flask(__name__)

# Celery 設定
app.config["CELERY_BROKER_URL"] = "redis://localhost:6379/0"
app.config["CELERY_RESULT_BACKEND"] = "redis://localhost:6379/0"
celery = Celery(app.name, broker=app.config["CELERY_BROKER_URL"])
celery.conf.update(app.config)

# OpenTelemetry の設定
tracer = trace.get_tracer("diceroller.tracer")


@app.route("/rolldice")
def roll_dice():
    username = get_username()

    # トレースコンテキストを取得して Celery に渡す
    headers = {}
    inject(headers)
    roll_dice_async.apply_async(kwargs={"username": username, "headers": headers})

    return "Rolling dice asynchronously!"


def get_username():
    with tracer.start_as_current_span("get_username") as span:
        username = "hawksnowlog"
        span.set_attribute("username", username)
        return username


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

lib/tasks.py

from random import randint

from opentelemetry import trace
from opentelemetry.propagate import extract

from lib.worker import celery

tracer = trace.get_tracer("diceroller.tracer")


@celery.task
def roll_dice_async(username, headers):
    return roll(username, headers)


def roll(username: str, headers):
    # OpenTelemetry のコンテキストを受け取り、トレースを継続
    with tracer.start_as_current_span("roll", context=extract(headers)) as span:
        res = randint(1, 6)
        span.set_attribute("roll.value", res)
        return res

lib/worker.py

from celery import Celery
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.instrumentation.celery import CeleryInstrumentor
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

# Celery インスタンスを作成
celery = Celery("tasks", broker="redis://localhost:6379/0")
celery.conf.update(broker_connection_retry_on_startup=True)

# OpenTelemetry の設定
tracer_provider = TracerProvider()
tracer_provider.add_span_processor(
    BatchSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317"))
)

# Celery の OpenTelemetry を有効化
CeleryInstrumentor().instrument()

起動

Flask

  • export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true && pipenv run opentelemetry-instrument --logs_exporter otlp --service_name dice-server python app.py

Celery

  • export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true && pipenv run opentelemetry-instrument --logs_exporter otlp --service_name dice-server celery -A lib.tasks worker --loglevel=INFO

Jaeger

  • docker run --rm --name jaeger -e COLLECTOR_ZIPKIN_HOST_PORT=:9411 -p 16686:16686 -p 4317:4317 -p 4318:4318 -p 14250:14250 -p 14268:14268 -p 14269:14269 -p 9411:9411 jaegertracing/all-in-one:latest

動作確認

localhost:8080/rolldice にアクセスして Jaeger にアクセスすると一つのトレース内に Flask と Celery のタスクが処理が含まれていることが確認できると思います

最後に

Flask + Celery に OLTP のトレースを設定する方法を紹介しました

トレースに間があるのは非同期特有なのかもしれません
もしくは BatchSpanProcessor を使っているのでリアルタイムでなくバッチ処理として送信している影響かもしれません

参考サイト

2025年3月12日水曜日

OpenTelemetry のトレース情報を Jaeger に直接送信する方法

OpenTelemetry のトレース情報を Jaeger に直接送信する方法

概要

これまではアプリ -> コレクタ -> exporter -> Jaeger という経路でトレース情報を送信していました
推奨ではないですがコレクタと exporter をすっ飛ばして直接トレース情報を Jaeger に送信することもできるので紹介します

環境

  • macOS 15.3.1
  • Python 3.12.9
    • flask 3.1.0
    • open-telemetry 0.51b0
  • OpenTelemetry Collector 0.121.0
  • Jaeger (all in one) 1.67.0

Jaeger 起動

  • docker run --rm --name jaeger -e COLLECTOR_ZIPKIN_HOST_PORT=:9411 -p 16686:16686 -p 4317:4317 -p 4318:4318 -p 14250:14250 -p 14268:14268 -p 14269:14269 -p 9411:9411 jaegertracing/all-in-one:latest

4317 で OTLP のトレース情報を直接受けます

アプリ起動

  • export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true && pipenv run opentelemetry-instrument --logs_exporter otlp --service_name dice-server python app.py

上記の設定では otlp は localhost:4317 に送信されます

app.py

前回と変わりませんが記載しておきます

from random import randint
from flask import Flask

from opentelemetry import trace

# Acquire a tracer
tracer = trace.get_tracer("diceroller.tracer")

app = Flask(__name__)

@app.route("/rolldice")
def roll_dice():
    username = get_username()
    return str(roll(username))

def get_username():
    with tracer.start_as_current_span("get_username") as rollspan:
        username = "hawksnowlog"
        rollspan.set_attribute("username", username)
        return username

def roll(username: str):
    # This creates a new span that's the child of the current one
    with tracer.start_as_current_span("roll") as rollspan:
        res = randint(1, 6)
        rollspan.set_attribute("roll.value", res)
        return res

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

動作確認

Jaeger を確認するとトレース情報が確認できます

最後に

おそらく OpenTelemetry が推奨する方法ではないですがこの方法でもトレース情報を送信できます
アプリのメトリックスを取得しない(トレースだけ)かつ OTLP の情報を Jaeger 以外に送らないのであればこの構成でもいいのかもしれません

参考サイト

2025年3月11日火曜日

OpenTelemetry のトレース箇所を独自で設定する方法

OpenTelemetry のトレース箇所を独自で設定する方法

概要

前回 OpenTelemetry を Flask アプリに適用する方法を紹介しました
前回の方法はトレースやメトリックス情報を自動で送信してくれる設定です
今回は手動でトレース情報を送信する方法を紹介します

環境

  • macOS 15.3.1
  • Python 3.12.9
    • flask 3.1.0
    • open-telemetry 0.51b0
  • OpenTelemetry Collector 0.121.0
  • Jaeger (all in one) 1.67.0

app.py

自動でトレースする設定は外し自分でトレースしたい箇所に設定します
基本的には tracer.start_as_current_span でトレースしたい箇所にスパンを作成するだけで OK です

以下の設定では2箇所でスパンを作成しています

from random import randint
from flask import Flask

from opentelemetry import trace

# Acquire a tracer
tracer = trace.get_tracer("diceroller.tracer")

app = Flask(__name__)

@app.route("/rolldice")
def roll_dice():
    username = get_username()
    return str(roll(username))

def get_username():
    with tracer.start_as_current_span("get_username") as rollspan:
        username = "hawksnowlog"
        rollspan.set_attribute("username", username)
        return username

def roll(username: str):
    # This creates a new span that's the child of the current one
    with tracer.start_as_current_span("roll") as rollspan:
        res = randint(1, 6)
        rollspan.set_attribute("roll.value", res)
        return res

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

各種起動

アプリ、Jaeger、コレクターを起動します

  • export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true && pipenv run opentelemetry-instrument --logs_exporter otlp --service_name dice-server python app.py
  • docker run --rm --name jaeger -e COLLECTOR_ZIPKIN_HOST_PORT=:9411 -p 16686:16686 -p 14317:4317 -p 14318:4318 -p 14250:14250 -p 14268:14268 -p 14269:14269 -p 9411:9411 jaegertracing/all-in-one:latest
  • docker run -p 4317:4317 -v $(pwd)/otel-collector-config.yaml:/etc/otel-collector-config.yaml otel/opentelemetry-collector:latest --config=/etc/otel-collector-config.yaml

動作確認

localhost:8080/rolldice にアクセスし Jaeger で確認しましょう
以下のように1つのリクエストに対して2つのスパンが作成されていれば OK です

最後に

OpenTelemetry を使って独自のトレース情報を設定する方法を紹介しました
自動の場合はスパンなどが細かく設定できないのでプロダクションでは独自設定を使うことになると思います

参考サイト

2025年3月10日月曜日

OpenTelemetry を使って flask アプリのトレースをしてみる

OpenTelemetry を使って flask アプリのトレースをしてみる

概要

アプリのリクエストがどこ、どのくらいの時間をかけてを通ったかなど可視化することができます

環境

  • macOS 15.3.1
  • Python 3.12.9
    • flask 3.1.0
    • open-telemetry 0.51b0
  • OpenTelemetry Collector 0.121.0
  • Jaeger (all in one) 1.67.0

各種インストール

  • pipenv install flask
  • pipenv install open-telemetry

app.py

from random import randint
from flask import Flask, request
import logging

app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


@app.route("/rolldice")
def roll_dice():
    player = request.args.get('player', default=None, type=str)
    result = str(roll())
    if player:
        logger.warning("%s is rolling the dice: %s", player, result)
    else:
        logger.warning("Anonymous player is rolling the dice: %s", result)
    return result


def roll():
    return randint(1, 6)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

OpenTelemetry 用のログを自動で出力するように設定する

作成したアプリに自動でログを出力してくれるようにします

  • pipenv run opentelemetry-bootstrap -a install

これで再度アプリを起動します
OpenTelemetry 用のログが自動で出力されるようになります

  • export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true && pipenv run opentelemetry-instrument --traces_exporter console --metrics_exporter console --logs_exporter console --service_name dice-server python app.py

これで起動し localhost:8080/rolldice にアクセスするとログが OpenTelemetry 形式になっているが確認できると思いますの

今回はすべてのログを自動で OpenTelemetry 形式にしているので flask の起動ログも OpenTelemetry 形式になっています

コレクターの起動

標準出力に吐かれているログをコレクターに送ることで可視化できます
まずはコレクターを起動します
コレクターは docker イメージが用意されているのでそれを使います

otel-collector-config.yaml

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
exporters:
  # NOTE: Prior to v0.86.0 use `logging` instead of `debug`.
  debug:
    verbosity: detailed
processors:
  batch:
service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [debug]
      processors: [batch]
    metrics:
      receivers: [otlp]
      exporters: [debug]
      processors: [batch]
    logs:
      receivers: [otlp]
      exporters: [debug]
      processors: [batch]

コンテナ起動

  • docker run -p 4317:4317 -v $(pwd)/otel-collector-config.yaml:/etc/otel-collector-config.yaml otel/opentelemetry-collector:latest --config=/etc/otel-collector-config.yaml

これで localhost:4317 にアクセスするとコレクタの画面が確認できます

ログをコレクターに送る

あとは先程標準出力に吐かれていた OpenTelemetry のログを起動したコレクターのエンドポイントに向けます

コレクターに送るためのプラグインが必要になるのでインストールします

  • pipenv install opentelemetry-exporter-otlp

そして再度アプリを OpenTelemetry ログ付きで起動します
console 時にくらべコマンドは短くなっています

  • export OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true && pipenv run opentelemetry-instrument --logs_exporter otlp --service_name dice-server python app.py

すると今度はアプリ側ではなくコレクター側に OpenTelemetry の情報が表示されるのが確認できると思います

トレース情報を Jaeger で可視化する

OpenTelemetry Collector は集めたデータを更に別の場所にエクスポートすることができます
トレースデータを Jaeger にエクスポートして WebUI で確認できるようにしてみます

まずは Jaeger を起動します

  • docker run --rm --name jaeger -e COLLECTOR_ZIPKIN_HOST_PORT=:9411 -p 16686:16686 -p 14317:4317 -p 14318:4318 -p 14250:14250 -p 14268:14268 -p 14269:14269 -p 9411:9411 jaegertracing/all-in-one:latest

いろいろポートを開いていますが今回必要なのは OTLP 用の 4317 (ホスト側は 14317) と WebUI 用の 16686 になります
4317 と 4318 はすでにコレクターで使っているので同一ホストで動作させる場合は別ポートをしていします

次に OpenTelemetry Collector の設定ファイルを編集して exporters を追加します

  • vim otel-collector-config.yaml
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318
exporters:
  debug:
    verbosity: detailed
  otlp/jaeger:
    endpoint: 192.168.1.88:14317
    tls:
      insecure: true
processors:
  batch:
service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlp/jaeger]
      processors: [batch]
    metrics:
      receivers: [otlp]
      exporters: [debug]
      processors: [batch]
    logs:
      receivers: [otlp]
      exporters: [debug]
      processors: [batch]

追加したのは exporters の部分です
ここに起動した Jaeger の OTLP ポートを指定しています
コレクターはコンテナで動いているので localhost ではなく Jaeger コンテナが動作しているホストの IP を指定しましょう
そして service.pipelines.traces.exporters を debug から otlp/jaeger に変更します

そしてコレクターを再起動しましょう

動作確認

Flask アプリ、OpenTelemetry Collector、Jaeger をそれぞれ起動し動作確認します
すべて起動できたら localhost:8080/rolldice にアクセスしましょう
そしてトレース情報を Jaeger で確認するため localhost:16686 にアクセスしてみましょう

今回追加した dice-server のトレースが見れるようになっていれば OK です

最後に

Flask アプリに OpenTelemetry を導入してトレース情報を Jaeger で可視化してみました
OpenTelemetry でトレースを可視化する流れは理解できたかなと思います

直接 OpenTelemetry 情報を Jaeger に送る方法はどうやら廃止されるらしく今後はコレクターを経由して Jaeger など別の可視化アプリなどに連携するのが主流になるようです

今回はすべてのログに自動で OpenTelemetry 化する方法を採用しましたがプロダクションなどであれば必要な箇所でスパンなどを定義して送信する必要があります
そのあたりの個別のスパンの作成方法なども機会があれば紹介したいと思います

参考サイト

2025年3月9日日曜日

Flask + distroless 超入門

Flask + distroless 超入門

概要

Flask を distroless 上で動かしてみました
ビルドは 3.12 ですが distroless の python は 3.11.2 なのでそこのバージョンは合わせたほうがいいかもしれません

環境

app.py

from flask import Flask

app = Flask(__name__)

@app.route("/")
def hello():
    return "Hello, Distroless with Pipenv!"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

Pipfile 作成

  • pipenv install flask

Dockerfile

# ビルド用のステージ
FROM python:3.12.9-slim-bookworm AS builder

WORKDIR /app

# Pipenv をインストール
RUN pip install --no-cache-dir pipenv

# Pipfile と Pipfile.lock をコピーしてインストール
COPY Pipfile Pipfile.lock ./
RUN PIPENV_VENV_IN_PROJECT=1 pipenv install --deploy --ignore-pipfile

# Flask アプリをコピー
COPY app.py .

# Distroless を使用する実行環境
FROM gcr.io/distroless/python3

WORKDIR /app

# Pipenv でインストールした仮想環境のライブラリをコピー
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app /app

# 環境変数を設定して Python が `.venv` を認識できるようにする
ENV PYTHONUNBUFFERED=1 \
    PIPENV_VENV_IN_PROJECT=1 \
    PYTHONPATH="/app/.venv/lib/python3.12/site-packages"

# Flask アプリを起動
CMD ["app.py"]

ビルド

  • docker build -t flask-distroless-pipenv .

起動

  • docker run --rm -p 8080:8080 flask-distroless-pipenv

動作確認

  • curl localhost:8080
Hello, Distroless with Pipenv!
  • docker images
REPOSITORY                  TAG       IMAGE ID       CREATED         SIZE
flask-distroless-pipenv     latest    9adef9f7e0d0   4 minutes ago   86.2MB

最後に

ビルドしたイメージは軽くなりますがシェルがないのでトラシュは大変そうです
そもそもビルド時には distroless なイメージは使えないのでビルド時のイメージを管理しなければいけないので結局ディストリビューションは必要かもしれません
また distroless で提供している Python のバージョンが 3.11.2 とかなり古いのでそこも微妙な感じはします

参考サイト

2024年9月20日金曜日

Flask でベーシック認証を実装する方法

Flask でベーシック認証を実装する方法

概要

特にライブラリを使わずに実装する方法を紹介します

環境

  • macOS 14.6.1
  • Python 3.11.10
  • Flask 3.0.3

デコレータを使う方法

from flask import Flask, Response, request

app = Flask(__name__)

# 認証情報(ユーザー名とパスワードをハードコーディング)
USERNAME = "admin"
PASSWORD = "password"


# 認証を行う関数
def check_auth(username, password):
    """ユーザー名とパスワードをチェックする"""
    return username == USERNAME and password == PASSWORD


# 認証を要求するレスポンスを返す関数
def authenticate():
    """認証を要求するレスポンスを返す"""
    return Response(
        "Could not verify your access level for that URL.\n"
        "You have to login with proper credentials",
        401,
        {"WWW-Authenticate": 'Basic realm="Login Required"'},
    )


# 認証のデコレーター
def requires_auth(f):
    """エンドポイントに認証を要求するデコレーター"""

    def decorated(*args, **kwargs):
        auth = request.authorization
        if not auth or not check_auth(auth.username, auth.password):
            return authenticate()
        return f(*args, **kwargs)

    return decorated


@app.route("/")
@requires_auth
def index():
    return "Hello, you are authenticated!"


if __name__ == "__main__":
    app.run(debug=True)

before_request を使ってすべてのルーティングに適用する方法

from flask import Flask, Response, request

app = Flask(__name__)

# 認証情報
USERNAME = "admin"
PASSWORD = "password"


# 認証をチェックする関数
def check_auth(username, password):
    return username == USERNAME and password == PASSWORD


# 認証が必要な場合にダイアログを表示する関数
def authenticate():
    return Response(
        "Could not verify your access level for that URL.\n"
        "You have to login with proper credentials",
        401,
        {"WWW-Authenticate": 'Basic realm="Login Required"'},
    )


# すべてのリクエストの前に認証を行う
@app.before_request
def before_request():
    if request.path == "/public":
        return  # 認証をスキップ
    auth = request.authorization
    if not auth or not check_auth(auth.username, auth.password):
        return authenticate()


# サンプルのルート
@app.route("/")
def index():
    return "Hello, you are authenticated!"


@app.route("/dashboard")
def dashboard():
    return "Welcome to your dashboard!"


if __name__ == "__main__":
    app.run(debug=True)

最後に

ライブラリを使う場合は Flask-HTTPAuth がいいかもしれまえん
Flask-BasicAuth はもうメンテナンスされていません

2024年1月12日金曜日

flask-pydantic が v2 に対応していたので試してみた

flask-pydantic が v2 に対応していたので試してみた

概要

過去に使ったときは pydantic v2 に対応していませんでした
flask-pydantic 0.12.0 で対応したようなので使ってみました

環境

  • macOS 11.7.10
  • Python 3.11.6
    • flask 3.0.0
    • pydantic 2.5.3
    • flask-pydantic 0.12.0

サンプルコード

from typing import Optional

from flask import Flask, jsonify
from flask_pydantic import ValidationError, validate
from pydantic import BaseModel, field_validator

app = Flask(__name__)
app.config["FLASK_PYDANTIC_VALIDATION_ERROR_RAISE"] = True


class ResponseModel(BaseModel):
    id: int
    age: int
    name: str
    nickname: Optional[str]


class RequestBodyModel(BaseModel):
    name: str
    nickname: Optional[str]

    @field_validator("name")
    def check_name(cls, v, values, **kwargs):
        if v != "hawksnowlog":
            raise ValueError()
        return v


@app.route("/", methods=["POST"])
@validate()
def post(body: RequestBodyModel):
    name = body.name
    nickname = body.nickname
    return ResponseModel(name=name, nickname=nickname, id=0, age=1000)


@app.errorhandler(ValidationError)
def handle_bad_request(_):
    return jsonify({"error": "validation_error"}), 400


app.register_error_handler(400, handle_bad_request)

所感

開発はまだまだ途中という感じかなと思います
エラーハンドリングの部分が微妙で今回 FLASK_PYDANTIC_VALIDATION_ERROR_RAISE というフラグを True にしています
これがない状態で ValueError を投げると json にシリアライズする部分でエラーになり json のエラー構文が返ってきません
なので flask-pydantic 側でハンドリングはせずにアプリ側でハンドリングを実装してあげる必要があります
これに関してはそのうち直る可能性はありそうですがそれ以外でもまだバグがありそうなので v2 対応はまだ様子見なのかもしれません

参考サイト

2024年1月11日木曜日

Flask で pydantic を使う方法を考える

Flask で pydantic を使う方法を考える

概要

過去にflask-pydanticを使った方法を紹介しました
しかし flask-pydantic は V2 に対応していません
今回は flask-pydantic なしで Flask + pydantic を連携する方法を考えます

環境

  • macOS 11.7.10
  • Python 3.11.6
    • flask 3.0.0
    • pydantic 2.5.3

案1: ルーティング内の先頭でモデルを使用する

from flask import Flask, jsonify, request
from pydantic import BaseModel, field_validator

app = Flask(__name__)


class User(BaseModel):
    name: str

    @field_validator("name")
    @classmethod
    def validate_name(cls, v):
        if v != "hawk":
            raise ValueError()
        return v


@app.route("/", methods=["POST"])
def test():
    data = request.json
    if data is None:
        raise ValueError()
    user = User(**data)
    return jsonify(user.model_dump())

メリット

  • 簡単、明瞭、特に何も考えず先頭で変換するだけなので楽

デメリット

  • 変換する部分の実装を忘れそう
  • request.json から変換しているが他にも query や path などをモデルに変換する部分の考慮しなければならない

案2: デコレータを使い引数で受け取る

from flask import Flask, jsonify, request
from pydantic import BaseModel, field_validator

app = Flask(__name__)


class User(BaseModel):
    name: str

    @field_validator("name")
    @classmethod
    def validate_name(cls, v):
        if v != "hawk":
            raise ValueError()
        return v


def validate(func):
    def wrapper(*args, **kwargs):
        data = request.json
        if data is None:
            raise ValueError()
        user = User(**data)
        return func(user, *args, **kwargs)

    return wrapper


@app.route("/", methods=["POST"])
@validate
def test(user: User):
    return jsonify(user.model_dump())

メリット

  • ルーティング側での変換が不要になるのでコードがきれいになる
  • 引数で受け取れてかつ検証済みのオブジェクトなので安全に扱える

デメリット

  • デコレータが肥大化しないように気をつける必要がある
    • モデルごとにデコレータを追加する必要がないようにする必要がある
  • request.json から変換しているが他にも query や path などをモデルに変換する部分の考慮しなければならない

最後に

頑張れば何とかなりそうだけどコードが複雑化しないように工夫する必要がありそうです
そのあたりを吸収してくれてくれているのが flask-pydantic なので最悪ライブラリを自分でメンテするのもありなのかもしれません