Open46

【Write Code Every Day】Python編

ピン留めされたアイテム
ななみななみ

これは何?

新年度を迎え、t_wadaさんのソフトウェアエンジニアとしての姿勢と心構えを改めて読んだ。読むたびに気合いが入るとても良いスライドなのでお気に入り。
改めて「毎日コードを書く」と「アウトプットする」を意識したいなと思ったので、【Write Code Every Day】と題して日々の開発状況を記録することにした。

第一弾の言語

第一弾として Python を選択。
業務ではほぼ使わない言語だが、t_wadaさんの資料にも「毎年少なくとも言語を1つ学習する」とあったので、これに倣い未履修の Python を選択した。

第一弾のゴール

記事のタイトルを入力として、OpenAI の API でその記事の要約と関連する質問を表示するWebアプリを公開する。

リポジトリ:https://github.com/nanami69/python-project

ななみななみ

2023/4/3

本日のゴール

  • Python での Hello World

やったこと

  • リポジトリの作成
  • pyenvのインストール
  • Pythonのインストール
  • index.pyの作成
  • 動作確認とpush

該当コミット

Hidden comment
Hidden comment
ななみななみ

2023/4/4

本日のゴール

  • コマンドラインから2つの整数を入力として受け付け、足した結果を出力する

やったこと

  • 関数定義
  • コマンドラインからの引数受付
  • 引数(文字列)を整数に変換

該当コミット

ななみななみ

Pythonでの関数の書き方

def func(x, y):
  result = x + y
  retrun result

関数定義の後の:と関数内のインデントを忘れない。

ななみななみ

sysモジュール

  • リファレンス
  • インタプリタで使用・管理している変数や、インタプリタの動作に深く関連する関数を定義
import sys

sys.argv

  • Pythonスクリプトに渡されたコマンドライン引数のリスト
  • 0番目の要素は通常スクリプト名になることに注意
  • また、受け取った引数は文字列となる
ななみななみ

2023/4/5

本日のゴール

  • 前日までに書いたコードのテストを書く

やったこと

  • sum関数のリファクタ
  • テストフレームワークの選定とpytestのインストール
  • テストコード追加

該当コミット

ななみななみ

Python テストフレームワーク

  • unittestpytestといったフレームワークがあるようだが、今回はpytestを選択
  • pytestunittestよりも Python らしく書けるらしい
  • また、pytestpython developers survey 2022 resultsで1番使われている

pytest のインストール

pip install pytest
ななみななみ

テスト実行したらエラー発生
テスト対象のコードはこれ

エラーメッセージ

test_index.py:1: in <module>
    from index import sum
index.py:6: in <module>
    num1 = int(sys.argv[1])
E   ValueError: invalid literal for int() with base 10: 'test_index.py'
=========================== short test summary info ============================
ERROR test_index.py - ValueError: invalid literal for int() with base 10: 'test_index.py'
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!

テストコード

from index import sum

def test_sum():
    result = sum(2, 3)
    assert result == 5

解析

  • どうやらテスト実行時にコマンドライン引数がなく、sys.argv[1]という文字列でint()が実行されてしまい怒られているっぽい
  • これは原因が分かってからの後付けだが、sum関数をimportしているだけなのにsum関数外の処理が走っているのがどうもおかしい
  • sum関数外の部分はPythonスクリプトが直接起動されたとき以外は実行しないようにする
if __name__ == "__main__":
    num1 = int(sys.argv[1])
    num2 = int(sys.argv[2])
    result = sum(num1, num2)
    print(result)
ななみななみ

上記のエラー解析時に、テストコードでコマンドライン引数を渡せれば良いのかと思って、その方法も調べたのでせっかくだし残しておく。

pytestでmockを使う

  • mocker.patch.objectを使う
    • モック対象のオブジェクトの特定の属性を置き換える場合に使用される

sys.argvをモックする

  • mocker.patch.objectを使ってsys.argvをモックする場合は以下のようになる
from index import sum
import sys

def test_sum(mocker):
    mocker.patch.object(sys, 'argv', ['test_index.py', '2', '3'])
    result = sum(int(sys.argv[1]), int(sys.argv[2]))
    assert result == 5
ななみななみ

2023/4/6

本日のゴール

  • Flaskで Hello World

やったこと

  • Flaskのインストール
  • 静的ファイル(html)の作成
  • index.pyの編集
  • 動作確認とテスト

該当コミット

ななみななみ

Flaskとは

Flask(フラスコ/フラスク)はPythonのWebアプリケーションフレームワークで、小規模向けの簡単なWebアプリケーションを作るのに適している。

pip install flask

2.2.3をインストールした。

ななみななみ
index.py
from flask import Flask
app = Flask(__name__)

// ルートのアドレスに以下のものを設置
@app.route('/')
def index():
    // `send_static_file`: 静的ファイルを提供するためのメソッド
    return app.send_static_file('index.html')

if __name__ == "__main__":
    app.run()
static/index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <title>python project</title>
</head>
<body>
<p>Hello World</p>
</body>
</html>

htmlファイルをstaticフォルダに入れない場合はindex.pyを以下のように変更すれば良い。

index.py
// ルートにindex.htmlを置いた場合
-app = Flask(__name__)
+app = Flask(__name__, static_folder='.')

ななみななみ

テストについて

flaskのテストにはpytest-flaskが必要。

pip install pytest-flask

テストコードは以下

test_index.py
from index import app

def test_index():
    // Flaskアプリケーションのテスト用クライアントを生成するメソッド
    // withステートメントを使用することで、テストが終了したら自動的にテストクライアントが閉じられる
    with app.test_client() as client:
        response = client.get('/')
        assert response.status_code == 200
        assert b'<!DOCTYPE html>' in response.data
ななみななみ

2023/4/7

本日のゴール

  • ルーティング設定をして2ページ間移動

やったこと

  • sub関数の追加とルーティング設定
  • テスト追加
  • 動作確認

該当コミット

ななみななみ

subページを追加。こちらはhtmlではなく単にThis is Sub Page!とだけ返す設定。

index.py
@app.route('/')
def index():
    return app.send_static_file('index.html')

@app.route('/sub')
def sub():
    return "This is Sub Page!"
static/index.html
<p>Hello World</p>
<a href="./sub">click</a>
</body>
ななみななみ

テストの追加

test_index.py
def test_sub():
    with app.test_client() as client:
        response = client.get('/sub')
        assert response.status_code == 200
        assert b'This is Sub Page!' in response.data
ななみななみ

2023/4/8

本日のゴール

  • Flaskアプリケーションでテンプレートを作成する
  • Jinja2テンプレートエンジンを用いてレンダリングする

やったこと

  • Jinja2 のインストール
  • templatesフォルダの作成
  • index.htmlの修正
  • index.pyの修正
    • テンプレートを呼び出す
    • 変数を引き渡す
  • テストの修正
  • 動作確認

該当コミット

ななみななみ

Jinja2

  • Jinja2はPythonで書かれたテンプレートエンジンの一つで、PythonのWebフレームワークであるFlaskやDjangoなどで広く使用される
  • Jinja2を使うことで、WebアプリケーションのView(表示)とLogic(ロジック)を分離することができる
pip install Jinja2
ななみななみ
index.py
from flask import Flask, render_template
app = Flask(__name__)

@app.route('/index')
def index():
    name = 'nanami'
    return render_template('index.html', name=name)

if __name__ == "__main__":
    app.run()
index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <title>python project</title>
</head>
<body>
<p>Hello World</p>
<h2>My name is {{ name }}</h2>
<a href="./sub">click</a>
</body>
</html>

ななみななみ

2023/4/9

本日のゴール

  • フォームの作成とPOSTメソッドの受け取り
  • 受け取ったテキストを用いて OpenAI の API を叩いて結果を表示する

やったこと

  • index.htmlにフォーム作成
  • index.pyindex関数で POST メソッドを受け取る処理追加
  • generate_summaryの実装
  • OPENAI_API_KEYを環境変数に設定
  • 動作確認
  • テスト追加

該当コミット

ななみななみ

POST での受け取り

index.html
<form method="POST">
    <label for="textbox">記事のタイトル:</label>
    <input type="text" id="textbox" name="textbox">
    <br>
    <input type="submit" value="送信">
</form> 

POST でデータを受け取ったらgenerate_summary関数にわたす

index.py
@app.route('/index', methods=['GET', 'POST'])
def index():
    name = 'nanami'
    if request.method == 'POST':
        title = request.form['textbox']
        summary = generate_summary(f"title and summarize {title}")
        return f"Summary: {summary}"
    return render_template('index.html', name=name)
ななみななみ

OpenAI の API を叩く

最初はopenaiモジュールを用いてopenai.ChatCompletion.createで叩いていたが、500エラーが解消されないので、https://api.openai.com/v1/completionsに対して普通に POST するやり方にした。

index.py
def generate_summary(prompt):
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"
    }

    prompt_text = f"「{prompt}」という記事を1000トークンで収まる内容で日本語で要約してください。"

    data = {
        "model": "text-davinci-003",
        "prompt": prompt_text,
        "max_tokens": 1000,
    }

    response = requests.post("https://api.openai.com/v1/completions", headers=headers, json=data)
    response_json = response.json()
    summary = response_json["choices"][0]["text"].strip()

    return summary

max_tokensは回答の最大トークン数(≒単語数)を指定する。
max_tokensを1000にしているので、prompt_textでも1000トークンで収まるよう指示している。
こうすることで返ってきた回答が途中で切れてしまうなんてことを防げる。

ななみななみ

また、OpenAI の API を叩くにはOPENAI_API_KEYを環境変数に設定する必要がある。

設定

export OPENAI_API_KEY="sk-xxxx"

確認

echo $OPENAI_API_KEY
ななみななみ

2023/4/10

本日のゴール

  • データベースの設計とSQLiteの導入

やったこと

  • テーブル設計
  • database.pyの作成
    • データベース作成とテーブル作成の処理の追加
  • テストの追加

該当コミット

ななみななみ

SQLite3

軽量でサーバーレスのデータベース管理システム。Pythonにも標準ライブラリとしてSQLite3が組み込まれており、簡単に利用することが可能。

connはデータベースに接続するためのオブジェクトであり、cursorはSQLクエリを実行するためのオブジェクト。cursorを使用して、データベース内のテーブルに対する操作(クエリの実行、レコードの挿入、更新、削除など)を行う。

DDL(Data Definition Language)

データベースのスキーマやオブジェクト(テーブル、ビュー、インデックスなど)の作成、変更、削除などの操作を行うための言語。
DDL文はデータベースの構造を変更するために使用され、SQL文はデータベースのデータを操作するために使用される。

ななみななみ

テーブル設計

列名 データ型 制約 説明
id INTEGER PRIMARY KEY 記事ID
title TEXT NOT NULL 記事のタイトル
summary TEXT NOT NULL 記事の要約
created TIMESTAMP NOT NULL 記事を作成した日時
database.py
# テーブル作成のためのDDL文
CREATE_TABLE_SQL = """
CREATE TABLE news_summary (
    id INTEGER PRIMARY KEY,
    title TEXT NOT NULL,
    summary TEXT NOT NULL,
    created_at DATETIME DEFAULT (DATETIME('now', 'localtime'))
);
"""

PRIMARY KEY制約がついたidカラムは自動的にインクリメントされる。また、created_atカラムには、行が追加された現在時刻が自動的に挿入される。

ななみななみ

2023/4/11

本日のゴール

  • ニュースのタイトルと翻訳された概要をデータベースに保存する処理の作成
  • データベースから保存した情報を取得する処理の作成

やったこと

  • 記事タイトルとサマリを保存する関数の追加
  • 記事タイトルとサマリを表示するページ(article.html)を作成
  • 記事一覧を表示するページ(list.html)を作成
  • データベースから記事一覧を取得する処理を追加
  • テスト追加

該当コミット

ななみななみ

INSERT INTO文を作成し、タイトルと要約をデータベースに保存する

# データベースにタイトルと要約を保存する
    conn = sqlite3.connect(DB_FILEPATH)
    cursor = conn.cursor()
    insert_sql = """
        INSERT INTO news_summary (title, summary)
        VALUES (?, ?)
    """
    cursor.execute(insert_sql, (title, summary))
    conn.commit()
    conn.close()

SELECT分でデータの取得&list.htmlに引き渡し

@app.route('/list')
def list():
    conn = sqlite3.connect(DB_FILEPATH)
    cursor = conn.cursor()
    select_sql = """
        SELECT title, summary FROM news_summary
    """
    cursor.execute(select_sql)
    rows = cursor.fetchall()
    conn.close()
    return render_template('list.html', rows=rows)
ななみななみ

テストについて

test_database.py
@pytest.fixture
def db_connection():
    conn = sqlite3.connect(DB_FILEPATH)
    yield conn
    conn.close()

def test_save_news_summary(db_connection):
    title = "Test Title"
    summary = "Test Summary"
    save_news_summary(title, summary)

    cursor = db_connection.cursor()
    cursor.execute(f"SELECT COUNT(*) FROM news_summary WHERE title = '{title}' AND summary = '{summary}'")
    assert cursor.fetchone()[0] == 1

pytest@pytest.fixtureデコレータを使用して、データベース接続をするためのdb_connection変数を定義している。

yield connは、データベースの接続を返し、その時点で処理を停止している。次にこの関数が呼び出された際には、停止した箇所から再開してその後の処理を実行することができる。このようにyieldを使うことで、大量のデータを処理しながらメモリ使用量を抑えることが可能になる。

ななみななみ

2023/4/12

本日のゴール

  • 記事に対する質問を英語と日本語で考えてもらい画面に表示する

やったこと

  • 質問を作成するgenerate_questionを追加
  • generate_question内で Open AI を2回(英語と日本語)叩くよう修正
  • article.htmlに質問内容を表示する処理の追加
  • テスト追加
  • 動作確認

該当コミット

ななみななみ

OpenAIに複数の質問を順番に投げるような場合、以下のprompt_text以外のプロパティは共通なので、そのまま複製すると冗長になっています。

data = {
        "model": "text-davinci-003",
        "prompt": prompt_text,
        "max_tokens": 1000,
    }

そこでテンプレートを用意し、それぞれのdataでは可変のプロパティのみ追記するようにする。

data_template = {
    "model": "text-davinci-003",
    "max_tokens": 1000,
}

data1 = {**data_template, "prompt": prompt_text1}
data2 = {**data_template, "prompt": prompt_text2}
ななみななみ

2023/4/13

本日のゴール

  • 記事一覧に質問の英語/日本語も表示する
  • /にアクセスした際もindex.htmlに遷移するようにする
  • index.htmlのスタイルを調整する

やったこと

  • list.htmlに質問の英語/日本語を渡して表示する処理を追加
  • 使っていないsubページを削除
  • /のルーティング設定(index.htmlへリダイレクト)
  • リダイレクト処理のテスト追加
  • index.htmlへ Bootstrap を導入
  • index.htmlのスタイル修正
  • 動作確認

該当コミット

ななみななみ

リダイレクト処理

URLのリダイレクトは redirect() 関数を使用する。
さらにURLを生成するために url_for('index') を使用する。

index.py
@app.route('/')
def root():
    return redirect(url_for('index'))

@app.route('/index', methods=['GET', 'POST'])
def index():
// 以下略

テストではwith app.test_request_context()のブロックを使用して、Flaskアプリケーションによるリクエストのテストをシミュレートする。app.test_client()メソッドを使用して、アプリケーションのテストクライアントを作成し、get()メソッドを使用して/にアクセスしている。

test_index.py
def test_root():
    with app.test_request_context():
        response = app.test_client().get('/')
        assert response.status_code == 302
        assert response.headers['Location'] == url_for('index')
ななみななみ

2023/4/15

本日の目標

  • エラーハンドリングの追加

やったこと

  • API呼び出し部分の関数化
  • エラーハンドリングの追加
  • プロンプト文の外部ファイル化
  • テストの追加
  • 動作確認

該当コミット

ななみななみ

エラーハンドリング

raise_for_status() は、HTTPのステータスコードが200番台以外(エラーが発生した)場合に例外を発生させる。つまり、リクエストが成功しなかった場合に例外を発生させ、例外を処理することができる。
raise_for_status() を呼び出すことで、エラーをキャッチすることができ、それに対する適切な対応を取ることができる。

index.py
def request_openai_api(prompt_text):
    data = {**data_template, "prompt": prompt_text}

    try:
        response = requests.post("https://api.openai.com/v1/completions", headers=headers, json=data)
        response.raise_for_status()  # ステータスコードが200以外の場合は例外を発生させる

        response_json = response.json()
        result = response_json["choices"][0]["text"].strip()

    except (BaseException, Exception) as e:
        raise ValueError("Invalid response from API: {}".format(str(e)))

    return result

エラー処理部分

except (BaseException, Exception) as e:
        raise ValueError("Invalid response from API: {}".format(str(e)))

BaseExceptionは、すべての組み込み例外の基底クラスであり、通常、Pythonで例外処理に関連する任意のクラスを定義する場合に使用される。
Exceptionは、通常、すべての通常の例外の基底クラスとして使用される。つまり、プログラムが予期しない状況に遭遇した場合に発生する例外。

ValueErrorはPythonで定義されている組み込みの例外の一つで、値が不正な場合に発生する。例えば、文字列を数値に変換しようとしたときに、数値として解釈できない文字列を与えた場合などに発生する。ValueErrorは、発生した例外が値が不正であることに関連する場合に使用される。

最後のraise文でValueError例外を発生させている。

ななみななみ

エラーハンドリングのテスト

@pytest.fixture()デコレータを使用して、requests_mockという名前のフィクスチャを作成。
フィクスチャは、テストで使用される事前に準備されたオブジェクトであり、この場合はモックオブジェクト。
テスト実行前にrequests_mockフィクスチャが呼び出される。

with mock.patch('index.requests') as mock_requests:は、index.pyrequestsモジュールの代わりにmock_requestsという名前のモックオブジェクトを使用するという意味。

yield mock_requests文は、モックリクエストオブジェクトを返し、テストが終了するまで維持する。

@pytest.fixture()
def requests_mock():
    with mock.patch('index.requests') as mock_requests:
        yield mock_requests

requests_mock.post.return_value.json.return_valueでpostメソッドの戻り値をモックしている。

def test_request_openai_api_successful(requests_mock):
    # Mock API response
    prompt_text = "Sample prompt"
    expected_result = "Sample response"
    requests_mock.post.return_value.json.return_value = {"choices": [{"text": expected_result}]}

    # Call function and check result
    result = request_openai_api(prompt_text)
    assert result == expected_result

requests_mock.post.return_value.status_code = 500では、post() メソッドがHTTPステータスコード500を返すように設定することで、APIから失敗の応答が返された場合に関数が適切に処理されることが確認することができる。

次に、requests_mock.post.side_effect = requests.exceptions.HTTPError()で、post()メソッドが HTTPError例外を発生させるように設定している。これにより、APIから失敗の応答が返された場合に関数が適切に例外を処理することが確認できる。

def test_request_openai_api_error(requests_mock):
    # Mock API response with error status code
    prompt_text = "Sample prompt"
    requests_mock.post.return_value.status_code = 500
    requests_mock.post.side_effect = requests.exceptions.HTTPError()

    # Call function and check that it raises an exception
    with pytest.raises(ValueError, match="Invalid response from API: "):
        request_openai_api(prompt_text)
ななみななみ

2023/4/16

本日のゴール

  • ヘッダーとファビコン追加

やったこと

  • ヘッダーの追加
  • ファビコンの追加
  • Submit ボタンの制御追加

該当コミット

ななみななみ

Flask のurl_for関数を使用して、アプリの静的ファイルディレクトリから画像ファイルを取得することができる。デフォルトの設定では、静的ファイルはstaticディレクトリに配置されるので、画像ファイルはstatic/imagesディレクトリに配置する。

<img src="{{ url_for('static', filename='images/header.jpeg') }}" alt="Header Image">
ななみななみ

↑↑↑ ここまでがコーディング編 ↑↑↑


↓↓↓ ここからがデプロイ編 ↓↓↓

デプロイ作業はコードを書かないこともあるので、厳密には【Write Code Every Day】からは外れるが、記録として残しておく。
なので、ここからは記録の日付が連続しない可能性があるが、【Write Code Every Day】第二弾として、2023/4/17からは Go × TypeScript(+Nuxt.js) 編を並行してスタートすることにする。
https://zenn.dev/nanami69/scraps/0949fde7e13599