【Python】PDM Scriptsでタスクランナーを作成する

[前回の記事](https://qiita.com/quag_cactus/items/70201acbdb4f9ad554e6)でPDMのスクリプト機能（`PDM Scripts`）についてまとめましたので、これをタスクランナーとして活用するためのユースケースを確認しようと思います。

## タスクランナーとして活用

今回は以下のCSV入出力をチェーンしていくプログラムスクリプトを用意します。

### テストソース

1. 行方向に数値が記述されたCSV(`CSV-0`)を横方向に変換したCSV(`CSV-1`)を出力するスクリプト

    <details><summary>ソースコード</summary>

    ```python:csv_conver.py
    import sys
    import pathlib
    import csv

    def Main(args):
        
        print(f'execute: {args[0]}')
        print(' args:', args)
        
        input_csv_path = pathlib.Path(args[1])
        print(' input file:', input_csv_path.resolve())
        
        output_csv_path = pathlib.Path(args[2])
        
        num_list = []
        with open(input_csv_path, 'r') as fs:
            reader = csv.reader(fs)
            for row in reader:
                num_list.append(row[0])

        with open(output_csv_path, 'w') as fs:
            writer = csv.writer(fs)
            writer.writerow(num_list)
            
        print(' output file:', output_csv_path.resolve())

        return
    
    if __name__ == '__main__':
        Main(sys.argv)
    ```

    </details>


1. `CSV-1`の数値にそれぞれ100を加えた値の行を追記したCSV(`CSV-2`)を出力するスクリプト

    <details><summary>ソースコード</summary>

    ```python:csv_adder.py
    import sys
    import pathlib
    import csv

    def Main(args):
        
        print(f'execute: {args[0]}')
        print(' args:', args)
        
        input_csv_path = pathlib.Path(args[1])
        print(' input file:', input_csv_path.resolve())
        
        output_csv_path = pathlib.Path(args[2])
        
        num_list = []
        with open(input_csv_path, 'r') as fs:
            reader = csv.reader(fs)
            for row in reader:
                num_list = [int(r) for r in row]  
                
        added_list = [n + 100 for n in num_list]

        with open(output_csv_path, 'w') as fs:
            writer = csv.writer(fs)
            writer.writerow(num_list)
            writer.writerow(added_list)
            
        print(' output file:', output_csv_path.resolve())

        return
    
    if __name__ == '__main__':
        Main(sys.argv)
    ```

    </details>

1. `CSV-2`の全ての数値に環境変数「`MULTI_NUM`」で定められた数値を乗算した結果のCSV(`CSV-3`)を出力するスクリプト

    <details><summary>ソースコード</summary>

    ```python:csv_multiplier.py
    import sys
    import pathlib
    import csv
    import os

    def Main(args):
        
        print(f'execute: {args[0]}')
        print(' args:', args)
        
        input_csv_path = pathlib.Path(args[1])
        print(' input file:', input_csv_path.resolve())
        
        output_csv_path = pathlib.Path(args[2])
        
        multi_num = int(os.environ['MULTI_NUM'])
        print(' multi num:', multi_num)
        
        ary = []
        with open(input_csv_path, 'r') as fs:
            reader = csv.reader(fs)
            updated_list = []
            for row in reader:
                ary.append([int(r) * multi_num for r in row])

        with open(output_csv_path, 'w') as fs:
            writer = csv.writer(fs)
            writer.writerows(ary)
            
        print(' output file:', output_csv_path.resolve())

        return
    
    if __name__ == '__main__':
        Main(sys.argv)
    ```

    </details>

これらのコードを順番（`csv_converter.py` -> `csv_adder.py` -> `csv_multiplier.py`）に動かすための実行可能な定義を行いたいと思います。

もちろん`sh`や`bat`で事足りるかもしれませんが、これらで記述する場合は、定義の中に仮想環境のアクティベートを含めてる必要があるかもしれません。なによりOSごとに実行例を書くのはメンテナンス性が落ちます。これを`PDM Scripts`で実現してみましょう。

### pyproject.tomlの定義

以上のソースコードを順番に実行するための`PDM Scripts`を`Pyproject.toml`の`tool.pdm.scripts`セクションにて定義していきます。

`csv_converter.py`を実行する一番手のスクリプト「`converter`」には、実行前に一時ファイル置き場を作成するプレスクリプトを定義しておきます。プレスクリプトは対象のスクリプト名に接頭辞「`pre`」をつけた名前のスクリプトを記述することで認識・定義されます。

`pyproject.toml`の作成は`pdm init`で行いました。今回もアプリケーションとして初期化しています。

詳しくは公式ドキュメントや前回記事を参照してください。

```toml

[project]
name = ""
version = ""
description = ""
authors = [
    {name = "quag-cactus", email = "quag.cactus@gmail.com"},
]
dependencies = []
requires-python = ">=3.9"
license = {text = "MIT"}

[tool.pdm.scripts]

# CSV-0 -> CSV-1
pre_converter.shell = "rm -r ./tmp/ && mkdir -p ./tmp/"
converter.shell = "python csv_converter.py {args} ./tmp/csv-1.csv"

# CSV-1 -> CSV-2
adder.shell = "python csv_adder.py ./tmp/csv-1.csv ./tmp/csv-2.csv"

# CSV-2 -> CSV3
multiplier.shell = "python csv_multiplier.py ./tmp/csv-2.csv csv-3.csv"
multiplier.env = {MULTI_NUM = "5"}

# タスクランナー
runner-all = {composite = [
    "converter {args}",
    "adder",
    "multiplier"
]}

```

プロジェクト全体のディレクトリ構成は以下のようになります。

```bash
# タスクランナー実行後のCSVファイルも含まれています。
$ tree
├── csv-0.csv
├── csv_adder.py
├── csv_converter.py
├── csv_multiplier.py
├── pyproject.toml
└── tmp
    ├── csv-1.csv
    └── csv-2.csv
```

### ランナー実行

以下のようなCSVを入力として、`pdm run`コマンドでタスクランナーを実行します。

```csv:csv-0.csv
1
2
3
4
5
```

```bash
# タスクランナー実行
$ pdm run runner-all csv-0.csv         
         
execute: csv_converter.py
 args: ['csv_converter.py', 'csv-0.csv', './tmp/csv-1.csv']
 input file: /home/ec2-user/environment/pdm-scripts-tester/csv-0.csv
 output file: /home/ec2-user/environment/pdm-scripts-tester/tmp/csv-1.csv
execute: csv_adder.py
 args: ['csv_adder.py', './tmp/csv-1.csv', './tmp/csv-2.csv']
 input file: /home/ec2-user/environment/pdm-scripts-tester/tmp/csv-1.csv
 output file: /home/ec2-user/environment/pdm-scripts-tester/tmp/csv-2.csv
execute: csv_multiplier.py
 args: ['csv_multiplier.py', './tmp/csv-2.csv', 'csv-3.csv']
 input file: /home/ec2-user/environment/pdm-scripts-tester/tmp/csv-2.csv
 multi num: 5
 output file: /home/ec2-user/environment/pdm-scripts-tester/csv-3.csv
```

```bash
# 出力されたCSVを確認
$ cat csv-3.csv

5,10,15,20,25
505,510,515,520,525
```

最初の引数の指定のみで、開発者が意図したフローでCSVファイルのIOが実行されました。また、今回はプレスクリプトによって中間ファイルも`tmp/`に保存してあります。

`multiplier`スクリプトで設定した環境変数も、このスクリプトのスコープのみ有効なので、シェルセッションを汚さない点でもポイント高いですね。

## まとめ

`PDM Scripts`の`composite`機能を活用して、タスクランナーを作成してみました。

ちょっとしたプロジェクトで、開発者の意図した使い方を伝える点では効果的と思います。素早く試すことのできる簡単なプログラムと、堅牢で実用性のあるコマンドライン引数の構成は、しばしば相反関係となります。
可読性や拡張性の面でもPythonスクリプト本体が受ける引数を柔軟に保ちつつ、PDMスクリプトで実用上の使い方を示すことができます。

ただ、この機能は開発コマンドのエイリアスを念頭に実装されていると思われます。ガチガチのタスクランナーを組むことは想定されていないと思います。

個人的にはPDMの機能で実現できるタスクランナーをある種の基準として、もし実現できない場合、プロジェクトに比して起動引数や設定が複雑すぎないかどうか、確認することにしています。
複雑なタスクランナーが実際に必要であれば、`Invoke`などを使いましょう。

もちろん、PDM「開発環境の管理ツール」であり、本番環境で使うものではありません。とはいえ、Pythonプログラムの実行をとりまき、避けては通れない仮想環境や依存パッケージ、さらにはCLIコマンドを一元的に管理することは、本番環境へのデプロイにも資することは間違いないと思います。

お勧めしているのかしていないのか、良く分からなくなってしまいましたが、開発者の想定・意向を即座に実行可能な形で伝えることは重要です。そのための一手段していかがでしょうか。

## 前回記事
* https://qiita.com/quag_cactus/items/70201acbdb4f9ad554e6

## 参考
* https://qiita.com/lucidfrontier45/items/6fef04b09d57979f3f28
* https://qiita.com/hjmr/items/be543a8766418e9c7090
* PDMドキュメント: https://pdm.fming.dev/latest/
* PDMドキュメント（スクリプト機能）: https://pdm.fming.dev/latest/usage/scripts/