argparse：ヘルプのフォーマットクラスを利用して、ヘルプメッセージを改善する（メッセージの改行、デフォルト値/requiredの表示）

# 実行環境
* Python 3.9.7


# はじめに
argparseでCLIを作っています。
デフォルトの設定だと、CLIのヘルプメッセージが分かりづらいです。

例として、以下のCLIのヘルプメッセージを表示してみます。


```python:cli.py
from argparse import ArgumentParser


def parse_args():
    parser = ArgumentParser(
        description=textwrap.dedent(
            """
            イメージを作成する。
            ``aws ec2 create-image`` コマンドを参考にした。
            """
        ).strip()
    )
    parser.add_argument(
        "--name",
        type=str,
        required=True,
        help=textwrap.dedent(
            """
            イメージの名前。
            制約：3-128文字の英数字。
            """
        ).strip(),
    )
    parser.add_argument(
        "--type",
        type=str,
        choices=["foo", "bar"],
        default="bar",
        help=textwrap.dedent(
            """
            イメージのタイプ。
             * foo: FOO
             * bar: BAR
            """
        ).strip(),
    )
    parser.add_argument(
        "--dry-run", action="store_true", help="イメージを作らずに、イメージを作る権限があるかどうかをチェックする。"
    )
    return parser.parse_args()


if __name__ == "__main__":
    parse_args()
```

※ `textwrap.dedent("""...""").strip()`としているのは、ヒアドキュメントの前後の空白を取り除くためです。

https://qiita.com/ykhirao/items/c7cba73a3a563be5eac6

```
$ python cli.py -h
usage: cli.py [-h] --name NAME [--type {foo,bar}] [--dry-run]

イメージを作成する。 ``aws ec2 create-image`` コマンドを参考にした。

optional arguments:
  -h, --help        show this help message and exit
  --name NAME       イメージの名前。 制約：3-128文字の英数字。
  --type {foo,bar}  イメージのタイプ。 * foo: FOO * bar: BAR
  --dry-run         イメージを作らずに、イメージを作る権限があるかどうかをチェックする。
```

以下の点で、ヘルプメッセージは分かりづらいです。

* コマンドの説明、引数の説明が改行されていない
* `--type`にデフォルト値が表示されていない

この2点を改善する方法を紹介します。

# 改善1：フォーマッタークラスを指定する
argparseには4つのフォーマットクラスが用意されています。これらのフォーマットクラスを複数継承したクラスを、`ArgumentParser`コンストラクタの`formatter_class`引数に指定します。

https://docs.python.org/ja/3/library/argparse.html#formatter-class

```python
from argparse import ArgumentParser, RawTextHelpFormatter, RawDescriptionHelpFormatter, ArgumentDefaultsHelpFormatter

class MyHelpFormatter(RawTextHelpFormatter, RawDescriptionHelpFormatter, ArgumentDefaultsHelpFormatter):
    pass

def parse_args():
    parser = ArgumentParser(
        description="""
        イメージを作成する。
        ``aws ec2 create-image`` コマンドを参考にした。
        """,
        formatter_class=MyHelpFormatter
    )
```

* RawTextHelpFormatter: 引数の説明で空白と改行がそのまま表示する。
* RawDescriptionHelpFormatter: descriptionとepilogで空白と改行がそのまま表示する。
* ArgumentDefaultsHelpFormatter: デフォルト値を表示する

```
$ python cli.py -h
usage: cli.py [-h] --name NAME [--type {foo,bar}] [--dry-run]

イメージを作成する。
``aws ec2 create-image`` コマンドを参考にした。

optional arguments:
  -h, --help        show this help message and exit
  --name NAME       イメージの名前。
                    制約：3-128文字の英数字。 (default: None)
  --type {foo,bar}  イメージのタイプ。
                     * foo: FOO
                     * bar: BAR (default: bar)
  --dry-run         イメージを作らずに、イメージを作る権限があるかどうかをチェックする。 (default: False)
```

コマンドの説明、引数の説明が改行されて、かつデフォルト値も表示されました。

# 設定2：引数と引数の説明の間に空行を入れる
引数の説明を改行して表示すると、引数と引数の区切りが分かりづらくなります。
なので、引数と引数の説明の間に空行を入れます。

先ほど作った`MyHelpFormatter`クラスに`_format_action`メソッドを定義して、オーバライドします。
`_format_action`メソッドは、`argparse.Action`クラスを文字列に変換する関数です。

https://github.com/python/cpython/blob/e5edc8d737a45d9d8b9b93b8be52f85d79d0f417/Lib/argparse.py#L503-L549

親クラスの`_format_action`メソッドの結果に対して、改行`\n`を付与することで、引数と引数の説明の間に空行ができます。

```python
class MyHelpFormatter(
    RawTextHelpFormatter, RawDescriptionHelpFormatter, ArgumentDefaultsHelpFormatter
):
    def _format_action(self, action: argparse.Action) -> str:
        return super()._format_action(action) + "\n"
```


```
$ python cli.py -h
...

optional arguments:
  -h, --help        show this help message and exit

  --name NAME       イメージの名前。
                    制約：3-128文字の英数字。 (default: None)

  --type {foo,bar}  イメージのタイプ。
                     * foo: FOO
                     * bar: BAR (default: bar)

  --dry-run         イメージを作らずに、イメージを作る権限があるかどうかをチェックする。 (default: False)
```

# 設定3：不要なデフォルト値を表示しない
上記のヘルプメッセージでは、すべての引数に対してデフォルト値が表示されます。
しかし、本当に表示して欲しいデフォルト値は`--type`だけです。
`--name`のデフォルト値`None`、`--dry-run`のデフォルト値`False`は自明なので、不要です。


`MyHelpFormatter`クラスに`_get_help_string`メソッドを定義して、オーバライドして対応します。
まずは、`ArgumentDefaultsHelpFormatter`クラスの`_get_help_string`メソッドの中身を、そのまま持ってきます。

https://github.com/python/cpython/blob/e5edc8d737a45d9d8b9b93b8be52f85d79d0f417/Lib/argparse.py#L695-L702

ヘルプメッセージにデフォルト値を追加する条件に、`action.default is not None and not action.const`を追加します。
`action.const`は、以下の場合にTrueになります。

* `parser.add_argument`のaction引数に`store_true`や`store_false`を指定したとき
* `parser.add_argument`のconst引数に値を指定したとき

https://docs.python.org/ja/3/library/argparse.html#const


```python
class MyHelpFormatter(
    RawTextHelpFormatter, RawDescriptionHelpFormatter, ArgumentDefaultsHelpFormatter
):
    ...

    def _get_help_string(self, action):
        help = action.help
        if "%(default)" not in action.help:
            if action.default is not SUPPRESS:
                defaulting_nargs = [OPTIONAL, ZERO_OR_MORE]
                if action.option_strings or action.nargs in defaulting_nargs:
                    # カスタム設定
                    if action.default is not None and not action.const:
                        help += " (default: %(default)s)"
        return help
```


```
$ python cli.py -h
usage: cli.py [-h] --name NAME [--type {foo,bar}] [--dry-run]

イメージを作成する。
``aws ec2 create-image`` コマンドを参考にした。

optional arguments:
  -h, --help        show this help message and exit

  --name NAME       イメージの名前。
                    制約：3-128文字の英数字。

  --type {foo,bar}  イメージのタイプ。
                     * foo: FOO
                     * bar: BAR (default: bar)

  --dry-run         イメージを作らずに、イメージを作る権限があるかどうかをチェックする。
```

`--type`のみデフォルト値が表示されました。

# 設定4：必須かどうかを明記する
コマンドライン引数が必須かどうかは、ヘルプメッセージの"usage"を見れば分かります。

```
usage: cli.py [-h] --name NAME [--type {foo,bar}] [--dry-run]
```

しかし、コマンドライン引数が多いと、"usage"は少し分かりづらいです。
そこで、コマンドライン引数の説明に、必須なら`required`と表示しましょう。
`_get_help_string`メソッドに、以下のコードを追記します。

```python
class MyHelpFormatter(
    RawTextHelpFormatter, RawDescriptionHelpFormatter, ArgumentDefaultsHelpFormatter
):

    def _get_help_string(self, action):
        help = action.help
        if action.required:
            help += " (required)"

        if "%(default)" not in action.help:
            ...

        return help

```


```
$ python cli.py -h

イメージを作成する。
``aws ec2 create-image`` コマンドを参考にした。

optional arguments:
  -h, --help        show this help message and exit

  --name NAME       イメージの名前。
                    制約：3-128文字の英数字。 (required)

  --type {foo,bar}  イメージのタイプ。
                     * foo: FOO
                     * bar: BAR (default: bar)

```

`--name`に`required`が表示されました。


# まとめ
以下のヘルプフォーマットクラスを利用すれば、ヘルプメッセージが見やすくなります。


```python
import argparse
from argparse import (OPTIONAL, SUPPRESS, ZERO_OR_MORE,
                      ArgumentDefaultsHelpFormatter, ArgumentParser,
                      RawDescriptionHelpFormatter, RawTextHelpFormatter)


class MyHelpFormatter(
    RawTextHelpFormatter, RawDescriptionHelpFormatter, ArgumentDefaultsHelpFormatter
):
    def _format_action(self, action: argparse.Action) -> str:
        return super()._format_action(action) + "\n"

    def _get_help_string(self, action):
        help = action.help
        if action.required:
            help += " (required)"
        
        if "%(default)" not in action.help:
            if action.default is not SUPPRESS:
                defaulting_nargs = [OPTIONAL, ZERO_OR_MORE]
                if action.option_strings or action.nargs in defaulting_nargs:
                    if action.default is not None and not action.const:
                        help += " (default: %(default)s)"
        return help
```


# 改善できなかったこと

## "usage"を適切に改行して見やすくする
コマンドライン引数が多いとき、ヘルプメッセージの"usage"は以下のように引数ごとに改行されれば、見やすくなります。

```
usage: cli.py [-h] 
 --name NAME
 [--type {foo,bar}]
 [--dry-run]
```

`argparse.HelpFormatter`クラスの`_format_actions_usage`メソッドの以下の部分を修正すれば、改行を入れることができそうです。

https://github.com/python/cpython/blob/20f53136679e260466a765de5befa3b9db396c9e/Lib/argparse.py#L482

```python
text = ' '.join([item for item in parts if item is not None])
# ↓
text = '\n'.join([item for item in parts if item is not None])
```

`_format_actions_usage`をオーバライドするには、`HelpFormatter._format_actions_usage`の中身を`MyHelpFormatter`クラスに持ってくる必要があります。
しかし、`HelpFormatter._format_actions_usage`の中身はステップ数が多いため、できれば`MyHelpFormatter`クラスに持ってきたくありません。バグの温床になりそうです。
したがって、この改善は諦めました。