- はじめに
- PoCで使用したスクリプトのサンプル
- ポータビリティの高いPythonパッケージ管理方法
- PEP 723 – Inline script metadata
- まとめ
- We are hiring!!!
はじめに
こんにちは!
株式会社ユーザベース SaaS Product Team の二木、松元、堀内です。
この記事では、直近の開発(PoC)で書いたPythonスクリプトを紹介します。
私たちは小さいPythonスクリプトを書き、それらをシェルスクリプトでつなぐという考え方を採用しました。
これによって、以下を成し遂げました!
- スクリプトの再利用性が高まった
- スクリプトをどの開発者の環境でも動かすことが容易になった
PoCで使用したスクリプトのサンプル
さっそくですが、直近のPoCで使用したPythonスクリプトとシェルスクリプトのざっくりとしたものを共有します。
PoCの中で、推論値のJSON Linesを入力し、評価や分析に用いるスコアを計算してCSVに出力したいシーンがありました。
入力 (JSON Lines)
別の推論用サービスが出力した推論値と、GroundTruthのセットとなっています。
{"id": "001", "actual": "生成された文字列", "expected": "期待される文字列"} {"id": "002", "actual": "生成されたよ", "expected": "期待されてるよ"}
出力 (CSV)
スコアとして編集距離を計算しています。
id,actual,expected,score 001,生成された文字列,期待される文字列,3 002,生成されたよ,期待されてるよ,4
小さなPythonスクリプト
この処理を実装する方法はいくつか考えられるかと思いますが、私たちは2つのPythonスクリプトを書くことにしました。
- evaluate.py
- 入力したJSON Linesにスコアのフィールドを追加するスクリプト
- jsonl_to_csv.py
- JSON LinesをCSVに変換するスクリプト
- evaluate.pyが出力するスクリプトに使うことを想定
evaluate.py
# /// script # dependencies = ["rapidfuzz", "jsonlines"] # /// from __future__ import annotations import argparse import sys from dataclasses import dataclass import jsonlines from rapidfuzz.distance import Levenshtein @dataclass class Args: input: argparse.FileType output: argparse.FileType if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("input", type=argparse.FileType("r"), default=sys.stdin) parser.add_argument( "-o", "--output", type=argparse.FileType("w"), default=sys.stdout ) args = parser.parse_args(namespace=Args) reader = jsonlines.Reader(args.input) writer = jsonlines.Writer(args.output) writer.write_all( {**r, "score": Levenshtein.distance(r["actual"], r["expected"])} for r in reader )
共通の工夫:入出力の扱い
今回のPythonスクリプトは入力や出力の扱いで工夫しています。
- 標準入力からもファイルからも入力できる
- 標準出力にもファイルにも出力できる
すなわちUnixコマンドのようなスクリプトにしたということです(例:cat
コマンド)。
それを実現しているのがこちらの箇所!
parser = argparse.ArgumentParser() parser.add_argument("input", type=argparse.FileType("r"), default=sys.stdin) parser.add_argument( "-o", "--output", type=argparse.FileType("w"), default=sys.stdout ) args = parser.parse_args(namespace=Args)
argparse.FileType
がポイントです。
https://docs.python.org/ja/3/library/argparse.html#filetype-objects
柔軟な入出力が数行のコードで実装できています(この後説明します)
入力の扱いの工夫
ファイルパスを渡せます。
% pipx run evaluate.py input.jsonl {"id": "001", "actual": "生成された文字列", "expected": "期待される文字列", "score": 3} {"id": "002", "actual": "生成されたよ", "expected": "期待されてるよ", "score": 4}
標準入力からも渡せて、そのときは-
も指定します。
% cat input.jsonl | pipx run evaluate.py - {"id": "001", "actual": "生成された文字列", "expected": "期待される文字列", "score": 3} {"id": "002", "actual": "生成されたよ", "expected": "期待されてるよ", "score": 4}
FileType オブジェクトは擬似引数 '-' を識別し (
argparse.FileType
のドキュメントより)
出力の扱いの工夫
入力ファイルだけ渡すと標準出力に出力します。
% pipx run evaluate.py input.jsonl {"id": "001", "actual": "生成された文字列", "expected": "期待される文字列", "score": 3} {"id": "002", "actual": "生成されたよ", "expected": "期待されてるよ", "score": 4}
-o
引数で出力ファイルも指定できます。
% pipx run evaluate.py input.jsonl -o output.jsonl % cat output.jsonl {"id": "001", "actual": "生成された文字列", "expected": "期待される文字列", "score": 3} {"id": "002", "actual": "生成されたよ", "expected": "期待されてるよ", "score": 4}
jsonl_to_csv.py
# /// script # dependencies = ["jsonlines"] # /// from __future__ import annotations import argparse import csv import sys from dataclasses import dataclass import jsonlines @dataclass class Args: input: argparse.FileType output: argparse.FileType if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("input", type=argparse.FileType("r"), default=sys.stdin) parser.add_argument( "-o", "--output", type=argparse.FileType("w"), default=sys.stdout ) args = parser.parse_args(namespace=Args) reader = iter(jsonlines.Reader(args.input)) first_row = next(reader) writer = csv.DictWriter(args.output, first_row.keys()) writer.writeheader() writer.writerow(first_row) writer.writerows(reader)
jsonl_to_csv.pyを見ると、「UNIXコマンドでもサクッとできるのでは?」と思われるかもしれません。
今回はブログ用に簡略化したのですが、実際のスクリプトではネストしたJSONをflattenする処理なども必要になっており、Pythonで書いたほうがよさそうだという判断をしています (Python熟練者が多いのも理由のひとつです)。
入出力の工夫として書いたargparse.FileType
をここでも使っています!
つなげるシェルスクリプト
入出力の工夫により、evaluate.pyとjsonl_to_csv.pyはつなげて動かせます!
% pipx run evaluate.py input.jsonl | pipx run jsonl_to_csv.py - id,actual,expected,score 001,生成された文字列,期待される文字列,3 002,生成されたよ,期待されてるよ,4
ちなみに別のコマンドの出力を入力として、動かすこともできます!
% cat input.jsonl | pipx run evaluate.py - | pipx run jsonl_to_csv.py - id,actual,expected,score 001,生成された文字列,期待される文字列,3 002,生成されたよ,期待されてるよ,4
このつなげて動かす部分をシェルスクリプト(main.sh
)として実装しました。
#!/usr/bin/env bash set -euo pipefail INPUT_JSONL=$1 DEFAULT_OUTPUT_CSV="${INPUT_JSONL%.jsonl}.csv" OUTPUT_CSV=${2:-"${DEFAULT_OUTPUT_CSV}"} BASE_DIR=$(dirname "$0") set -x pipx run "${BASE_DIR}/evaluate.py" "${INPUT_JSONL}" | pipx run "${BASE_DIR}/jsonl_to_csv.py" - -o "${OUTPUT_CSV}"
% ./main.sh input.jsonl % cat input.csv id,actual,expected,score 001,生成された文字列,期待される文字列,3 002,生成されたよ,期待されてるよ,4
ポータビリティの高いPythonパッケージ管理方法
ポータビリティを高くするために、私たちは今回pipx
というツールを採用しました。
pipxは大きく2つのコマンドを提供します
pipx install
pipx run
Pythonで開発していてPyPIからインストールするライブラリには、コードの中でimport
しないライブラリがありますよね(例:Ruff)。
そういったライブラリはpipxのドキュメントではアプリケーションと呼ばれます。
pipx install
はアプリケーションをpipxが管理する個別の仮想環境にインストールします1。
開発者が管理する仮想環境ごとにインストールする方法もありますが、一度pipx install
すれば、そのマシンでは永続的にそのアプリケーションを使えます。
pipx install
は過去の記事でも紹介されています。
今回は、もう1つのpipx run
の方を使いました。
こちらはアプリケーションを渡すこともできますし2、スクリプトを渡すこともできます。
渡したアプリケーションやスクリプトはpipxが管理する仮想環境で実行されます。
この仮想環境は一時的なものです(ただし、都度作り直すわけではなくpipxが一定期間キャッシュします)。
スクリプトを実行する場合、後述するPEP 723がサポートされたがゆえに、高いポータビリティを享受できます
pipxのインストールはこちらをどうぞ
https://pipx.pypa.io/stable/#install-pipx
brew
やapt
で入りますし、Pythonをインストールしてpython -m pip install --user pipx
とすることもできます。
pipx ensurepath
をお忘れなく
PEP 723 – Inline script metadata
inline script metadataとは
スクリプトの先頭の コメント について説明します。
# /// script # dependencies = ["rapidfuzz", "jsonlines"] # ///
このコメントは「inline script metadata」と呼ばれます。
PEP 723で仕様化されていて、Acceptされました。
metadataに書けるものは2つです
- dependencies
- スクリプトが実行時に依存するライブラリを列挙できます
- requires-python
- スクリプトが互換なPythonのバージョンを書けます
PEP中の例3
# /// script # requires-python = ">=3.11" # dependencies = [ # "requests<3", # "rich", # ] # ///
このmetadataの意味は以下です
- Python 3.11以上互換のスクリプト
- スクリプト実行時は、requestsの3系未満とrichに依存
pipxはinline script metadataのdependenciesをサポート
pipx run
は「inline script metadata」のdependenciesを解釈します。
pipx run script.py
を実行したとき
- pipxは仮想環境を作成する
- pipxがその仮想環境にdependenciesをインストールする
- その仮想環境を有効にした状態でscript.pyを実行
ポイントは、ある開発者の環境でpipx run
で動かせたスクリプトは、他の開発者の環境でもpipx run
で動かせるということです(=高いポータビリティ)。
pipxが仮想環境を管理するため、開発者は仮想環境の管理から解放されています。
比較のため、従来のやり方の一例です
- 開発者が仮想環境を作る
- 開発者が仮想環境に依存ライブラリをインストール
- 開発者が仮想環境を有効にした状態でスクリプトを実行
従来のやり方は、他の開発者も同じ手順を実行する必要がありますよね。
そのため、スクリプトのポータビリティが高いとは言えないかなと思われます。
スクリプトを渡すだけでは十分ではなく、仮想環境にインストールしたライブラリも合わせて共有する必要があります。
高いポータビリティは、私たちの開発スタイルと非常に馴染みました。
私たちはペアプログラミングで開発していて、1時間程度でペアを交代します(以下の記事の「1時間に一度は休憩とペアチェンジを行う」)。
スクリプトを動かす環境が1時間程度で変わるということですが、開発者が手順を実行する代わりにpipxに任せることでとても楽になりました。
また、開発者がちょっとしたライブラリを気軽に追加できるよさもありました。
例えば、出力形式を表にしたくなったときに、tabulateを追加するといったことです。
開発者は「inline script metadata」のdependenciesに加えるだけでよく、開発環境が変わってもpipxが依存ライブラリを追加してくれます。
今回はpipxを紹介しましたが、「inline script metadata」のdependenciesをサポートしたツールを使えば、開発者ではなくツールが仮想環境の管理を担ってくれるので、別の開発者の環境でも再現性高く実行できます
PEP 723によると、pipxの他にpip-runもあるそうです。
また、Acceptされたので、今後もサポートするツールが増えていくと思われます。
まとめ
直近の開発で書いたPythonスクリプトの紹介でした。
- 小さいPythonスクリプトを書いた
- ファイルへの入出力も標準入出力もサポート(
argparse
のFileType
) - スクリプトをつなげて動かす(今回はシェルスクリプトを書いた)
- ファイルへの入出力も標準入出力もサポート(
- スクリプトをどの開発者の環境でも動かせるのを重視した
- PEP 723をサポートした
pipx run
で実行
- PEP 723をサポートした
UnixコマンドのようなPythonスクリプトは、まだまだ極める余地がありそうです。
チームで実装する中で『Unixという考え方』が挙がりました。
機会があれば続編を書きたいと思います。
pipxやその他ツールのPEP 723関連の今後の発展に期待大ですね!
We are hiring!!!
ブログを最後まで読んでくださりありがとうございました。 ユーザベースでは、SaaS Product Team のメンバーを募集しています! 本ブログが、ユーザベースへ関心を持っていただくきっかけになれば幸いです!