【Write Code Every Day】Python編
これは何?
新年度を迎え、t_wadaさんのソフトウェアエンジニアとしての姿勢と心構えを改めて読んだ。読むたびに気合いが入るとても良いスライドなのでお気に入り。
改めて「毎日コードを書く」と「アウトプットする」を意識したいなと思ったので、【Write Code Every Day】と題して日々の開発状況を記録することにした。
第一弾の言語
第一弾として Python を選択。
業務ではほぼ使わない言語だが、t_wadaさんの資料にも「毎年少なくとも言語を1つ学習する」とあったので、これに倣い未履修の Python を選択した。
第一弾のゴール
記事のタイトルを入力として、OpenAI の API でその記事の要約と関連する質問を表示するWebアプリを公開する。
2023/4/3
本日のゴール
- Python での
Hello World
やったこと
- リポジトリの作成
-
pyenv
のインストール -
Python
のインストール -
index.py
の作成 - 動作確認と
push
該当コミット
2023/4/4
本日のゴール
- コマンドラインから2つの整数を入力として受け付け、足した結果を出力する
やったこと
- 関数定義
- コマンドラインからの引数受付
- 引数(文字列)を整数に変換
該当コミット
2023/4/5
本日のゴール
- 前日までに書いたコードのテストを書く
やったこと
-
sum
関数のリファクタ - テストフレームワークの選定と
pytest
のインストール - テストコード追加
該当コミット
Python テストフレームワーク
-
unittest
やpytest
といったフレームワークがあるようだが、今回はpytest
を選択 -
pytest
はunittest
よりも Python らしく書けるらしい - また、
pytest
がpython 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
をインストールした。
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()
<!DOCTYPE html>
<html lang="ja">
<head>
<title>python project</title>
</head>
<body>
<p>Hello World</p>
</body>
</html>
htmlファイルをstatic
フォルダに入れない場合はindex.py
を以下のように変更すれば良い。
// ルートにindex.htmlを置いた場合
-app = Flask(__name__)
+app = Flask(__name__, static_folder='.')
テストについて
flask
のテストにはpytest-flask
が必要。
pip install pytest-flask
テストコードは以下
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
sub
ページを追加。こちらはhtml
ではなく単にThis is Sub Page!
とだけ返す設定。
@app.route('/')
def index():
return app.send_static_file('index.html')
@app.route('/sub')
def sub():
return "This is Sub Page!"
<p>Hello World</p>
<a href="./sub">click</a>
</body>
テストの追加
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
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()
<!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.py
のindex
関数で POST メソッドを受け取る処理追加 -
generate_summary
の実装 -
OPENAI_API_KEY
を環境変数に設定 - 動作確認
- テスト追加
該当コミット
POST での受け取り
<form method="POST">
<label for="textbox">記事のタイトル:</label>
<input type="text" id="textbox" name="textbox">
<br>
<input type="submit" value="送信">
</form>
POST でデータを受け取ったらgenerate_summary
関数にわたす
@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 するやり方にした。
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 | 記事を作成した日時 |
# テーブル作成のための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)
テストについて
@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') を使用する。
@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()
メソッドを使用して/
にアクセスしている。
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/14
本日のゴール
- デザインをそれっぽくする
やったこと
-
article.html
,list,html
のスタイル修正
該当コミット
index/html
article.html
list.html
2023/4/15
本日の目標
- エラーハンドリングの追加
やったこと
- API呼び出し部分の関数化
- エラーハンドリングの追加
- プロンプト文の外部ファイル化
- テストの追加
- 動作確認
該当コミット
エラーハンドリング
raise_for_status()
は、HTTPのステータスコードが200番台以外(エラーが発生した)場合に例外を発生させる。つまり、リクエストが成功しなかった場合に例外を発生させ、例外を処理することができる。
raise_for_status()
を呼び出すことで、エラーをキャッチすることができ、それに対する適切な対応を取ることができる。
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.py
のrequests
モジュールの代わりに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) 編を並行してスタートすることにする。
2023/4/17
本日のゴール
- Elastic Beanstalk でサンプルアプリケーションを作る
やったこと