Pythonスクリプトのモジュラリティとポータビリティを高めていく

はじめに

こんにちは!

株式会社ユーザベース 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
brewaptで入りますし、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を実行したとき

  1. pipxは仮想環境を作成する
  2. pipxがその仮想環境にdependenciesをインストールする
  3. その仮想環境を有効にした状態でscript.pyを実行

ポイントは、ある開発者の環境でpipx runで動かせたスクリプトは、他の開発者の環境でもpipx runで動かせるということです(=高いポータビリティ)。
pipxが仮想環境を管理するため、開発者は仮想環境の管理から解放されています。

比較のため、従来のやり方の一例です

  1. 開発者が仮想環境を作る
  2. 開発者が仮想環境に依存ライブラリをインストール
  3. 開発者が仮想環境を有効にした状態でスクリプトを実行

従来のやり方は、他の開発者も同じ手順を実行する必要がありますよね。
そのため、スクリプトのポータビリティが高いとは言えないかなと思われます。
スクリプトを渡すだけでは十分ではなく、仮想環境にインストールしたライブラリも合わせて共有する必要があります。

高いポータビリティは、私たちの開発スタイルと非常に馴染みました。
私たちはペアプログラミングで開発していて、1時間程度でペアを交代します(以下の記事の「1時間に一度は休憩とペアチェンジを行う」)。

スクリプトを動かす環境が1時間程度で変わるということですが、開発者が手順を実行する代わりにpipxに任せることでとても楽になりました。

また、開発者がちょっとしたライブラリを気軽に追加できるよさもありました。
例えば、出力形式を表にしたくなったときに、tabulateを追加するといったことです。
開発者は「inline script metadata」のdependenciesに加えるだけでよく、開発環境が変わってもpipxが依存ライブラリを追加してくれます。

今回はpipxを紹介しましたが、「inline script metadata」のdependenciesをサポートしたツールを使えば、開発者ではなくツールが仮想環境の管理を担ってくれるので、別の開発者の環境でも再現性高く実行できます

PEP 723によると、pipxの他にpip-runもあるそうです。
また、Acceptされたので、今後もサポートするツールが増えていくと思われます。

まとめ

直近の開発で書いたPythonスクリプトの紹介でした。

  • 小さいPythonスクリプトを書いた
    • ファイルへの入出力も標準入出力もサポート(argparseFileType
    • スクリプトをつなげて動かす(今回はシェルスクリプトを書いた)
  • スクリプトをどの開発者の環境でも動かせるのを重視した
    • PEP 723をサポートしたpipx runで実行

UnixコマンドのようなPythonスクリプトは、まだまだ極める余地がありそうです。
チームで実装する中で『Unixという考え方』が挙がりました。
機会があれば続編を書きたいと思います。

pipxやその他ツールのPEP 723関連の今後の発展に期待大ですね!

We are hiring!!!

ブログを最後まで読んでくださりありがとうございました。 ユーザベースでは、SaaS Product Team のメンバーを募集しています! 本ブログが、ユーザベースへ関心を持っていただくきっかけになれば幸いです!

www.uzabase.com

Page top