argparse のテクニック未満集


argparse は Python の標準ライブラリの一つで、主にコマンドライン引数を扱うためのライブラリです。標準であることもあいまって「Python コマンドライン引数」とかでググるとたくさん解説が出てきます(無論、argparse よりも便利であることをアピールするサードパーティライブラリもいくつかありますが)。

一方で、あまり argparse のより踏み込んだ機能について解説している記事を見ない気もします。そこで本記事ではそういった応用的な使い方についていくつか取り上げていこうかと思います。とはいっても普通に公式リファレンスに書いてあることではありますが。

## 表向きと内部で引数名を変えたい

ユーザー向けの引数名とコード上での名前を別にしたい場合があります。そういう場合にはコード上での名前を `ArgumentParser#add_argument` の引数 `dest` に渡すことで実現できます。

```python:main.py
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--hoge", dest="fuga")

args = parser.parse_args()
# print(args.hoge) # AttributeError
print(args.fuga)
```

```shell
python main.py --hoge foo
```

```
foo
```

この機能は内部的なコードを変更しながらもユーザー向けインタフェースを変更したくない場合などにも有用でしょう。

## type(type ではない)

基本的に argparse の `ArgumentParser` によってパースされた引数は `str` 型となりますが、たとえば `int` 型で取得したいときには `ArgumentParser#add_argument` の引数として `type=int` を与えると `int` 型で引数を取得することができます。

```python:main.py
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("hoge")
parser.add_argument("fuga", type=int)

args = parser.parse_args()
print(type(args.hoge))
print(type(args.fuga))
```

```shell
python main.py 1 1
```

```
<class 'str'>
<class 'int'>
```

ここで、`type` という引数名に気をとられているとわかりづらいのですが、実は `type` が要求しているのは型名ではなく **`str` 一つを引数にとる呼び出し可能オブジェクト**だったりします。

なので、`type` 引数はたとえば引数に与えられた文字列を小文字化してから取り込みたい、といった要求にも応えることができます。

```python:main.py
import argparse


def to_lower(s: str):
    return s.lower()


parser = argparse.ArgumentParser()
parser.add_argument("hoge", type=to_lower)

args = parser.parse_args()
print(args.hoge)
```

```shell
python main.py SAMPLE
```

```
sample
```

ちなみに、引数が存在しなかったときに使用される `default` の値に対しては `type` に指定したオブジェクトを適用しませんが、`default` 引数の値が文字列だった場合は別で、`type` のオブジェクトが適用されます。この仕様は忘れがちなので注意しましょう。~~なんでそんな仕様にしたんだ?~~

## nargs を極める

`ArgumentParser#add_argument` の `nargs` は複数の値を一つの引数に割り当てるための引数です。たとえば一つの引数が二つの値を取り込む場合、以下のように書きます。

```python:main.py
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("hoge", nargs=2)

args = parser.parse_args()
print(args.hoge)
```

```shell
python main.py foo bar
```

```
['foo', 'bar']
```

見ての通り、`nargs` を設定するとリストで格納されます。これは `nargs=1` とした場合でも同様です。

`nargs` には整数だけでなく `"*"` `"+"` といった文字列を指定することもできます。意味は正規表現のそれと同じく「0 を含む任意個数」「0 を含まない任意個数」となります。

```python:main.py
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("hoge", nargs="*")

args = parser.parse_args()
print(args.hoge)
```

```shell
python main foo bar baz qux
```

```
['foo', 'bar', 'baz', 'qux']
```

正規表現ライクな文字列として、ほかに `"?"` が使用できます。これは省略可能な位置引数を表現するために使えます。この文字列についてはほかの `nargs` と異なりリストではなく入力された値そのものが取得され、入力がない場合は `default` 引数の値(初期値は `None`)となる点は注意が必要です。

```python:main.py
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("hoge", nargs="?")

args = parser.parse_args()
print(args.hoge)
```

```shell
python main.py foo
```

```
foo
```

```shell
python main.py
```

```
None
```

なお、先の節で解説した通り、`type` 引数はパースされた引数の型を指定するのではなく文字列の処理方法を指定するものです。そのため、`nargs` を指定したとしても **`type` には `list` を指定するべきではありません**。うっかり指定してしまうと次のようなかなり非直感的なことになります。

```python:main.py
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("hoge", nargs="*", type=list)

args = parser.parse_args()
print(args.hoge)
```

```shell
python main.py foo bar baz
```

```
[['f', 'o', 'o'], ['b', 'a', 'r'], ['b', 'a', 'z']]
```

このような結果になるのは、たとえば `"foo"` を `list` に与えて `["f", "o", "o"]` となり、さらにそれらをリストにまとめたためです。

逆に、複数の整数を受け取りたいときは単に `type=int` でよいことになります。

```python:main.py
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("hoge", nargs="*", type=int)

args = parser.parse_args()
print(args.hoge)
print(type(args.hoge[0]))
```

```shell
python main.py 1 2 3
```

```
[1, 2, 3]
<class 'int'>
```

## action いろいろ

多くの人は `"store_true"` くらい (あって `"store_false"`) しか使わないと思いますが、`action` 引数にはほかにも様々なアクションを指定できます。

`"store_const"` は指定されると `const` に指定した任意の定数を保持します。`action="store_true"` はおおまかに `action="store_const", const=True, default=False` の略記だと考えることができます。「指定するかしないかの二択にしたいけど `bool` 型以外で扱いたい」というときには `"store_const"` の出番です。

```python:main.py
import argparse

parser = argparse.ArgumentParser()
parser.add_argument(
    "--use-gpu",
    action="store_const",
    const="gpu",
    default="cpu",
    dest="device",
)

args = parser.parse_args()
print(args.device)
```

```shell
python main.py
```

```
cpu
```

```shell
python main.py --use-gpu
```

```
gpu
```

`action="append"` を指定した引数は指定されるたびにその値をリストに追加します。`nargs="*"` との違いは、複数個指定する際その度に名前を書く必要があるということです。どちらの引数仕様が相応わしいかは各アプリケーションによるでしょう。一つ注意点があり、一度も指定されなかった場合は `default` に指定した値(未指定なら `None`)となるため、**通常は `default=[]` を明示的に与える必要があります**。~~なんでそんな仕様にしたんだその 2~~

```python:main.py
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("--hoge", action="append", default=[])

args = parser.parse_args()
print(args.hoge)
```

```shell
python main.py --hoge foo --hoge bar --hoge baz
```

```
['foo', 'bar', 'baz']
```

```shell
python main.py
```

```
[]
```

`"append_const"` は `"store_const"` と `"append"` を組み合わせたような効果を持ちます。以下は引数名によって異なる値を共通したリストに追加していく用法です。

```python:main
import argparse

parser = argparse.ArgumentParser()
parser.add_argument(
    "--hoge",
    action="append_const",
    const="hoge",
    default=[],  # これが必要なのやっぱおかしいよ
    dest="names",
)
parser.add_argument(
    "--fuga",
    action="append_const",
    const="fuga",
    dest="names",
)
parser.add_argument(
    "--piyo",
    action="append_const",
    const="piyo",
    dest="names",
)

args = parser.parse_args()
print(args.names)
```

```shell
python main.py --hoge --piyo
```

```
['hoge', 'piyo']
```

`"append_const"` を独立した変数に指定して、リストの長さから指定された回数を求めようと思ったあなたはちょっと待ってください。そのような目的には `"count"` アクションがぴったりです。注意点として一度も指定されなかった場合は `default` に指定した値となるため(数十行ぶり二度目)、**通常は `default=0` を明示的に与える必要がある**ということです。~~なんでそんな仕様にしたんだその 3~~

```python:main.py
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("-v", action="count", default=0)

args = parser.parse_args()
print(args.v)
```

```shell
python main.py -vvv
```

```
3
```

```shell
python main.py
```

```
0
```

## Namespace 継承のススメ

そもそもパース結果の型はなんなのでしょう?　確かめてみましょう。

```python:main.py
import argparse

parser = argparse.ArgumentParser()
parser.add_argument("hoge")

args = parser.parse_args()
print(type(args))
```

```shell
python main.py foo
```

```
<class 'argparse.Namespace'>
```

`Namespace` というクラスであることがわかりました。

実はこの `Namespace` を継承したクラスを作り、パース結果としてそちらを使わせることができます。その場合、`ArgumentParser#parse_args` に `namespace` 引数としてそのクラスのインスタンスを与えてやれば OK です。やってみましょう。クラス名ではなくインスタンスを与える必要があります。

```python:main.py
import argparse


class MyNamespace(argparse.Namespace):
    pass


parser = argparse.ArgumentParser()
parser.add_argument("hoge")

args = parser.parse_args(namespace=MyNamespace())
print(type(args))
```

```shell
python main.py foo
```

```
<class '__main__.MyNamespace'>
```

自作クラスを使うメリットはいくつかあります。一つは引数に明示的な型ヒントを与えられることです。やってみましょう。

```python:main.py
import argparse


class MyNamespace(argparse.Namespace):
    str_value: str
    int_value: int


def int_function(x: int) -> None:
    pass


parser = argparse.ArgumentParser()
parser.add_argument("str_value")
parser.add_argument("int_value", type=int)

args = parser.parse_args(namespace=MyNamespace())
int_function(args.str_value)  # Lint Failed
int_function(args.int_value)
```

このコードを静的検査にかけると `int_function(args.str_value)` で型違反が発生します。これはすべての引数が `Any` に推論されるデフォルトの `Namespace` では発見できないものです。

Pythonista には静的型に興味がない人も多いでしょうが他にもメリットがあります。たとえば自作のクラスにはメソッドを追加できます。以下は引数をもとに別の値を計算するサンプルです。名字と名前からフルネームを計算しています。

```python:main.py
import argparse


class MyNamespace(argparse.Namespace):
    def full_name(self):
        return f"{self.first_name} {self.family_name}"


parser = argparse.ArgumentParser()
parser.add_argument("first_name")
parser.add_argument("family_name")

args = parser.parse_args(namespace=MyNamespace())
print(args.full_name())
```

```shell
python main.py John Doe
```

```
John Doe
```

引数に関する処理をクラスの中にまとめてしまうのもいいアイデアなのではないでしょうか。以下はパーサーの定義をクラスメソッドとして持たせ、さらにパース後のインスタンスを取得する処理もクラスメソッドにしてしまう例です。

```python:main.py
import argparse
from typing import Sequence


class MyNamespace(argparse.Namespace):
    @classmethod
    def parser(cls):
        parser = argparse.ArgumentParser()
        parser.add_argument("first_name")
        parser.add_argument("family_name")
        return parser

    @classmethod
    def parse(cls, args: Sequence[str] | None = None):
        parser = cls.parser()
        return parser.parse_args(args, namespace=cls())

    def full_name(self):
        return f"{self.first_name} {self.family_name}"


args = MyNamespace.parse()
print(args.full_name())
```

```shell
python main.py John Doe
```

```
John Doe
```

パース処理を専用クラスに移譲したことにより、なんらかの追加の操作をまとめてそちらに記述することもできます。たとえばバリデーションを追加してみましょう。たとえば名前と名字はそれぞれ 10 文字以内でなければならない、というバリデーションです。

```python:main.py
import argparse
from typing import Sequence


class MyNamespace(argparse.Namespace):
    @classmethod
    def parser(cls):
        parser = argparse.ArgumentParser()
        parser.add_argument("first_name")
        parser.add_argument("family_name")
        return parser

    @classmethod
    def parse(cls, args: Sequence[str] | None = None):
        parser = cls.parser()
        result = parser.parse_args(args, namespace=cls())
        result.validate()
        return result

    def validate(self):
        if len(self.first_name) > 10 or len(self.family_name) > 10:
            raise ValueError

    def full_name(self):
        if self.first_name is None:
            return self.family_name
        if self.family_name is None:
            return self.first_name
        return f"{self.first_name} {self.family_name}"


args = MyNamespace.parse()
print(args.full_name())
```

```shell
python main.py John Doe
```

```
John Doe
```

```shell
python main.py Arnold Schwarzenegger
```

```
Traceback (most recent call last):
  File "C:\Sandbox\argparse-test\main.py", line 32, in <module>
    args = MyNamespace.parse()
           ^^^^^^^^^^^^^^^^^^^
  File "C:\Sandbox\argparse-test\main.py", line 17, in parse
    result.validate()
  File "C:\Sandbox\argparse-test\main.py", line 22, in validate
    raise ValueError
ValueError
```

また、メソッドを持たせることができるということはプロパティを持たせることもできるということです。プロパティはフィールドと同じアクセス文法を持ちますので、引数の仕様変更を引数クラスで吸収するといった応用ができます。

たとえば以下の例を見てください。当初の引数仕様では二つの座標をカンマ区切りで入力することになっていました。

```python:main.py
import argparse
from typing import Sequence


class MyNamespace(argparse.Namespace):
    coordinate: str

    @classmethod
    def parser(cls):
        parser = argparse.ArgumentParser()
        parser.add_argument("coordinate")
        return parser

    @classmethod
    def parse(cls, args: Sequence[str] | None = None):
        parser = cls.parser()
        result = parser.parse_args(args, namespace=cls())
        result.validate()
        return result

    def validate(self):
        comma_index = self.coordinate.find(",")
        if comma_index < 0:
            raise ValueError
        _ = int(self.coordinate[:comma_index])
        _ = int(self.coordinate[comma_index + 1 :])


args = MyNamespace.parse()
print(args.coordinate)
```

```shell
python main.py 2,3
```

```
2,3
```

しかし、途中からそれぞれの座標値をスペース区切りで入力させたいということになりました。それぞれの座標値は `int` 型で `x` と `y` という名前で参照できるようにもしたい、でもこれまでの `coordinate` を使っている箇所を修正したくない、となったとします。そんな要求は見捨てろというツッコミはおいておくとして、以下のように対応できます。

```python:main.py
import argparse
from typing import Sequence


class MyNamespace(argparse.Namespace):
    x: int
    y: int

    @classmethod
    def parser(cls):
        parser = argparse.ArgumentParser()
        parser.add_argument("x", type=int)
        parser.add_argument("y", type=int)
        return parser

    @classmethod
    def parse(cls, args: Sequence[str] | None = None):
        parser = cls.parser()
        result = parser.parse_args(args, namespace=cls())
        result.validate()
        return result

    def validate(self):
        pass

    @property
    def coordinate(self):
        return f"{self.x},{self.y}"


args = MyNamespace.parse()
print(args.coordinate)
print(args.x)
print(args.y)
```

```shell
python main.py 2 3
```

```
2,3
2
3
```

さらに、やっぱり前の仕様で慣れてる人も多いから戻してくれ、ただスペース区切りでの入力に慣れた人もいるだろうからそれはそのままできるようにして、さらにカンマのあとにスペースが入っててもエラーにならないようにしてほしい、みたいな依頼が来たとします。**もうそのクライアント＊せ**というツッコミはおいておくとして、以下のように対応できます。

```python:main.py
import argparse
from typing import Sequence


class MyNamespace(argparse.Namespace):
    coordinate_a: str
    coordinate_b: str | None

    @classmethod
    def parser(cls):
        parser = argparse.ArgumentParser()
        parser.add_argument("coordinate_a")
        parser.add_argument("coordinate_b", nargs="?")
        return parser

    @classmethod
    def parse(cls, args: Sequence[str] | None = None):
        parser = cls.parser()
        result = parser.parse_args(args, namespace=cls())
        result.validate()
        return result

    def validate(self):
        if self.coordinate_b is None:
            comma_index = self.coordinate.find(",")
            if comma_index < 0:
                raise ValueError
            _ = int(self.coordinate_a[:comma_index])
            _ = int(self.coordinate_a[comma_index + 1 :])
        else:
            _ = int(self.coordinate_a.strip(","))
            _ = int(self.coordinate_b.strip(","))
        pass

    @property
    def coordinate(self):
        if self.coordinate_b is None:
            return self.coordinate_a
        return f"{self.coordinate_a.strip(",")},{self.coordinate_b.strip(",")}"

    @property
    def x(self):
        comma_index = self.coordinate.find(",")
        return int(self.coordinate[:comma_index])

    @property
    def y(self):
        comma_index = self.coordinate.find(",")
        return int(self.coordinate[comma_index + 1 :])


args = MyNamespace.parse()
print(args.coordinate)
print(args.x)
print(args.y)
```

```shell
python main.py 2,3
```

```
2,3
2
3
```

```shell
python main.py 2 3
```

```
2,3
2
3
```

```shell
python main.py 2, 3
```

```
2,3
2
3
```

## 結び

ひとまずはこんなところかと思います。みなさん良き argparse ライフを!(なにそれ?)