【Python】PDMのスクリプト機能を試してみる

## PDMについて

PDMはPythonのサードパーティのプロジェクト・パッケージ管理ツールです。

Pythonでは、npm(Node.js)やCargo(Rust)のようなプロジェクトに紐づいたパッケージ管理機能がPython標準で存在しません。そのためいくつかのサードパーティーツールの開発が進んでおり、その中の一つがPDMです。

よりメジャーなものに`Poetry`があります。私自身も`Poetry`をメインに使ってきましたが、惜しい部分として`npm-scripts`のようなユーザー定義可能なスクリプト機能がないことが挙げられます（アドオンはある）。PythonではFlake8やpytestなど、所定の手順とコマンドで実行してほしい手続きが結構多いです。

今回使用する`PDM`は`Poetry`と同等（またはそれより速い）依存解決パフォーマンスを、さらに`Poetry`より後発であるが故の独自仕様の少なさ（PEPへの忠実さ）が売りの管理ツールですが、スクリプト機能が存在する点も、Poetryと差別化できる大きなポイントです。

poetryと同等の活用方法や依存関係解決に関する話題は、先達の皆様から既にいくつか出ていますので、このスクリプト機能に焦点を紹介したいと思います。

テストプロジェクトをPDM管理下で作成し、いろいろ試してみましょう。

## テスト環境

* OS: Amazon lunux 2 (EC2 t2.micro)
* Python: 3.9.14
* pyenv: 2.3.21-2-ga2dff480
* PDM: 2.7.4

## PDMのインストール

pyenv及びPythonに必要な依存パッケージの導入は済んでいる前提で記述します。

今回はAmazon linux 2（cloud9）で実行しているので、プレインストールされているopensslをバージョン1.1.1以上に切り替えた上で、Pythonをインストールする必要があります。

```bash

# PDM内部で使用されているurllib3 v2.*ではopenssl1.1.1が必要です
# Amazon Linuxではopenssl11というパッケージでインストールできます
# あらかじめ旧バージョン(1.0.2)のopensslを削除しておく必要があります
# 詳細: https://github.com/urllib3/urllib3/issues/3016
sudo yum remove -y openssl openssl-devel
sudo yum install -y openssl11 openssl11-devel

# Pyenvで任意のバージョンをインストール
# PDMで管理する環境はPython3.7以上である必要があります（パッケージのビルドはそれより古いバージョンをターゲットにすることが可能）
pyenv install 3.9.14
pyenv global 3.9.14

# PDMをインストール
curl -sSL https://pdm.fming.dev/dev/install-pdm.py | python3 -
```

## プロジェクト初期化

1. test-project配下に同名プロジェクトを作成していきます。
    - PDMでは空ディレクトリを先に作成する必要があるみたいです（？）

    ```bash
    mkdir test-project && cd $_
    pdm init
    ```

1. 使用するインタプリタを選択します
    ```bash
    Please enter the Python interpreter to use
    0. /home/ec2-user/.pyenv/shims/python3 (3.9)
    1. /home/ec2-user/.pyenv/shims/python (3.9)
    2. /home/ec2-user/.pyenv/versions/3.9.14/bin/python3.9 (3.9)
    3. /home/ec2-user/.pyenv/shims/python3.9 (3.9)
    4. /usr/bin/python3.7m (3.7)
    5. /usr/bin/python3.7 (3.7)
    6. /usr/bin/python2.7 (2.7)
    7. /home/ec2-user/.local/share/pdm/venv/bin/python (3.9)
    Please select (0): 2
    ```

1. 選択したインタプリタのvenv環境を作成するか選択します。

    ```bash
    Would you like to create a virtualenv with /home/ec2-user/.pyenv/versions/3.9.14/bin/python3.9? [y/n] (y):y
    -> Virtualenv is created successfully at /home/ec2-user/environment/test-project/.venv
    ```

    **注意**: PDMが率先して対応していた、`__pypackages__`を使用したパッケージ管理手法（PEP-582）はリジェクトされてしまいました。PDM公式でも現在はvenvを用いるよう推奨しています。

    決意に満ちたメッセージは以下。

    > This is a rejected PEP. However, due to the fact that this feature is the reason for PDM's birth, PDM will retain the support. We recommend using virtual environments instead.
    > 
    > これは拒否されたPEPです。しかしながら、この機能がPDMの誕生の理由であるため、サポートは維持します。仮想環境の使用をお勧めします。
    > 
    > https://pdm.fming.dev/latest/usage/pep582/

1. ライブラリの開発を行うのか、アプリケーションの開発を行うのか選びます

    Noを選ぶと、エントリ部分の存在するアプリと見なされ、パッケージの説明や対応するPythonバージョンの指定などに関する質問がスキップされます（Pyprocjet.tomlでも該当部分が空白になります）

    ```bash
    Is the project a library that is installable?
    If yes, we will need to ask a few more questions to include the project name and build backend [y/n] (n): n
    ```

1. あとはライセンスやAutorなどなど一般的な質問が続くので、良しなに回答してください

    ```bash
    License(SPDX name) (MIT): 
    Author name (): quag-cactus
    Author email ():  
    Python requires('*' to allow any) (>=3.9): 
    Changes are written to pyproject.toml.
    ```

以上でpyproject.tomlが自動生成され、test-project/配下をpdm管理下のプロジェクトとして使用できます。

Poetryと違い、`tests/`や`src/`は自動生成されません。

今回の題材はスクリプト機能なので割愛しますが、作成したプロジェクト配下では`pdm add`コマンドを用いて、依存関係を追加していくことができます。

## スクリプト機能

本題のスクリプト機能を確認していきましょう。
スクリプトは`pyproject.toml`の[tool.pdm.scripts]セクションの中に記述します。

定義したスクリプトは`pdm run <script_name>`で使用します

## 基本機能

1. コマンド文字列

    `<script_name>.cmd = <command>`で、単純なコマンドエイリアスとして使用できます。今回はインストールしていませんが、pytestやflake8のエイリアスに便利ですね。

    単純に`<script_name> = <cmd>`とした場合も、コマンド文字列として認識されます。

    ```toml
    [tool.pdm.scripts]
    test-script.cmd = "echo Hello PDM!"
    ```

    ```bash
    # スクリプト実行
    $ pdm run test-script
    Hello PDM!
    ```

1. シェルモード

    `<script_name>.shell = "<shellscript>"`とすることで、右辺はシェルスクリプトとして実行されます。より複雑なタスクを定義できます。

    `<script_name>.cmd`をしようした場合も同様ですが、スクリプト機能で実行したPythonスクリプトはPDMで管理構築している環境内で実行されます。毎回`pdm run`をはさまなくても良いということです。

    ```toml
    # 標準出力をリダイレクト
    [tool.pdm.scripts]
    echo-redirect.shell = "python -c 'import math; print(round(math.pi, 8))' > ./output.txt"
    ```

    ```bash
    # 実行結果
    $ pdm run echo-redirect
    $ cat output.txt
    3.14159265
    ```

1. 環境変数
    `<script_name>.env`を定義することで、そのスクリプト実行時の環境変数を定義できます。複数設定可能です。

    個人的には、sys.pathにパスを追加するときに重宝しています。

    ```toml
    [tool.pdm.scripts]
    # syspathの追加パスを指定した上で実行
    add-syspath.shell = "python -c 'import sys;print(sys.path[1])'"
    add-syspath.env = {PYTHONPATH = "/FOO/HOGE"}
    ```

    ```bash
    # 実行結果
    $ pdm run add-syspath
    "/FOO/HOGE"
    ```

1. 引数の取り扱い

    プレースホルダ(`{args}`)を使用することで、スクリプトに引数を与えることができます。

    プレースホルダとして用意されているキーワードは`{args}`のみです。また、スクリプトに与えられた引数が複数であっても、それをすべて受け取って展開します。そのため、プレースホルダを変数のように扱えるわけではありません。

    ```toml
    [tool.pdm.scripts]
    # 基本の記法
    check-args.cmd = "echo {args}"
    # 引数を与えなかった場合のデフォルト値を設定可能
    check-args-default.shell = "echo {args:this-is-deafult-value}"
    # 複数のargsを設定した場合の挙動
    check-args-multi.shell = "echo {args} {args}"
    ```

    ```bash
    # 実行結果
    $ pdm run check-args xxx
    xxx

    $ pdm run check-args-default
    this-is-deafult-value

    $ pdm run check-args-default yyy
    yyy

    # 与えられた引数がプレースホルダの位置にそれぞれ展開される
    $ pdm run check-args-multi zzz
    zzz zzz

    # 複数の引数を与えた場合、引数すべてがプレースホルダに展開される
    $ pdm run check-args-multi aaa bbb
    aaa bbb aaa bbb
    ```

1. Pre/Postスクリプト

     定義したスクリプト名に`pre_`及び`post_`の接頭辞をつけたものは、当該スクリプトの前後にhookされ実行されます。
    
    ※hookされたスクリプトでは、`{args}`プレースホルダは使用できません

    ```toml
    pre_main = "echo -> This is PRE-MAIN"
    main = "echo -> This is MAIN"
    post_main = "echo -> This is POST-MAIN"
    ```

    ```bash
    # 実行結果
    $ pdm run main
    -> This is PRE-MAIN
    -> This is MAIN
    -> This is POST-MAIN
    ```

    hookスクリプトは他にも種類があります。以下のタイミングでスクリプトを設定できます。プロジェクト管理で便利なhookも多いですね。

    <details><summary>hookスクリプト一覧</summary>

    * post_init：`pdm init`後に実行します
    * pre_install: パッケージをインストールする前に実行します
    * post_install: パッケージのインストール後に実行します
    * pre_lock: 依存関係を解決する前に実行します
    * post_lock: 依存関係の解決後に実行します
    * pre_build: ディストリビューションを構築する前に実行します
    * post_build: ディストリビューションの構築後に実行します
    * pre_publish: ディストリビューションを公開する前に実行します
    * post_publish: ディストリビューションが公開された後に実行します
    * pre_script: スクリプトの前に実行します
    * post_script: スクリプトの後に実行
    * pre_run: 実行スクリプトを呼び出す前に1回だけ実行します
    * post_run: 実行スクリプトの呼び出し後に1回だけ実行します

    </details>

    `pre/post_script`と`pre/post_run`の違いは、前者がスクリプトが実行される（呼び出される）毎に必ずhookされ、後者は実行全体の前後でのみhookされます。使いどころは次の複合スクリプトを使った場合ですので、そちらの例を確認してください。

 
1. 複合スクリプト

    `composite`属性を登録したタスクを作成し、その要素としてタスク名の配列を定義することで、定義されたタスクを順番に起動します。

    ```toml
    # これまで定義したスクリプトを連続して実行
    test-runner = {composite = [
        "test-script",
        "add-syspath",
        "check-args xxx",
        "check-args {args}"
    ]}
    ```

    ```bash
    # 実行結果
    $ pdm run test-runner
    Hello PDM!
    /FOO/HOGE
    xxx
    yyy
    ```

    途中のスクリプトでエラーが発生した場合は、その時点で実行がストップします。

    ```toml
    [pdm.tool.scripts]

    # Pythonのゼロ除算エラーを送出するタスク
    test-err.shell = "python -c 'print(10 / 0)'"

    # 複合スクリプト
    test-runner-err = {composite = [
    "test-script",
    "test-err",
    "add-syspath",
    "check-args xxx",
    "check-args {args}"
    ]}

    ```

    ```bash
    # 実行結果
    $ pdm run test-runner-err
    Hello PDM!
    Traceback (most recent call last):
    File "<string>", line 1, in <module>
    ZeroDivisionError: division by zero
    ```

    また、復号スクリプトはネストした定義も可能になっています。それぞれに前後スクリプトを設定することもできます。

    下の例では複合スクリプトのネストに加え、`pre/post_script`と`pre/post_run`がhookされた挙動も示しています。

    ```toml
    [tool.pdm.scripts]
    
    # hookスクリプト
    pre_script = "echo <This is PRE-SCRIPT>"
    post_script = "echo <This is POST-SCRIPT>"
    pre_run = "echo <This is PRE_RUN>"
    post_run = "echo <This is POST_RUN>"

    # メインスクリプト
    pre_main = "echo -> This is PRE-MAIN"
    main = "echo -> This is MAIN"
    post_main = "echo -> This is POST-MAIN"
    
    # 内部複合スクリプト内の実行スクリプト
    internal_1 = "echo --> This is INTERNAL-1"
    internal_2 = "echo --> This is INTERNAL-2"
    
    # 内部（ネスト）複合スクリプト
    pre_internal_runner = "echo --> This is PRE-INTERNAL-RUNNER"
    internal_runner.composite = ["internal_1", "internal_2"]
    post_internal_runner = "echo --> This is POST-INTERNAL-RUNNER"
    
    # 全体実行複合スクリプト
    pre_main_runner = "echo This is PRE-MAIN-RUNNER"
    main_runner.composite = ["main", "internal_runner"] 
    post_main_runner = "echo This is POST-MAIN-RUNNER"
    ```

    ```bash
    # 実行結果
    $ pdm run main_runner
    <This is PRE_RUN>
    <This is PRE-SCRIPT>
    This is PRE-MAIN-RUNNER
    <This is PRE-SCRIPT>
    -> This is PRE-MAIN
    -> This is MAIN
    -> This is POST-MAIN
    <This is POST-SCRIPT>
    <This is PRE-SCRIPT>
    --> This is PRE-INTERNAL-RUNNER
    <This is PRE-SCRIPT>
    --> This is INTERNAL-1
    <This is POST-SCRIPT>
    <This is PRE-SCRIPT>
    --> This is INTERNAL-2
    <This is POST-SCRIPT>
    --> This is POST-INTERNAL-RUNNER
    <This is POST-SCRIPT>
    This is POST-MAIN-RUNNER
    <This is POST-SCRIPT>
    <This is POST_RUN>
    ```

    ちょっと見にくくなってしまいましたが、感覚通りの実行順となっているはずです。


## まとめ

PDMはパッケージ開発を前提にしているpoetryと比べ、アプリケーション開発も考慮したツールになっていると感じます。その理由の一つが`PDM Scripts`です。

pipenvやRyeにもユーザー定義可能なスクリプト機能は搭載されていますが、機能面ではPDMのそれが最も充実しています。

特に「作ってみた」系のミニアプリや実証スクリプトをPDM仮想環境で管理&`PDM Scripts`で実行コマンドを短くまとめてもらっていると、自環境で試すためのハードルがグっと下がるのではないでしょうか。

私自身、アプリ開発では今まで（無理やり）Poetryで管理していたところをPDMに移行しつつあります。移行も簡単なので、Poetryで`poetry install --no-root`をよく実行する方におすすめです。

もっとメジャーにならないかなあ。`__pypackages__`こそ採用されませんでしたが、前述したようにアプリケーション開発に対応したpyenv環境・依存関係管理ツールとしてまだまだ可能性があると思います。

## 参考

* 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/