7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PythonAdvent Calendar 2024

Day 8

argparse のテクニック未満集

Last updated at Posted at 2024-12-07

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

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

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

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

main.py
import argparse

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

args = parser.parse_args()
# print(args.hoge) # AttributeError
print(args.fuga)
python main.py --hoge foo
foo

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

type(type ではない)

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

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))
python main.py 1 1
<class 'str'>
<class 'int'>

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

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

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)
python main.py SAMPLE
sample

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

nargs を極める

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

main.py
import argparse

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

args = parser.parse_args()
print(args.hoge)
python main.py foo bar
['foo', 'bar']

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

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

main.py
import argparse

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

args = parser.parse_args()
print(args.hoge)
python main foo bar baz qux
['foo', 'bar', 'baz', 'qux']

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

main.py
import argparse

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

args = parser.parse_args()
print(args.hoge)
python main.py foo
foo
python main.py
None

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

main.py
import argparse

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

args = parser.parse_args()
print(args.hoge)
python main.py foo bar baz
[['f', 'o', 'o'], ['b', 'a', 'r'], ['b', 'a', 'z']]

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

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

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]))
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" の出番です。

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)
python main.py
cpu
python main.py --use-gpu
gpu

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

main.py
import argparse

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

args = parser.parse_args()
print(args.hoge)
python main.py --hoge foo --hoge bar --hoge baz
['foo', 'bar', 'baz']
python main.py
[]

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

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)
python main.py --hoge --piyo
['hoge', 'piyo']

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

main.py
import argparse

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

args = parser.parse_args()
print(args.v)
python main.py -vvv
3
python main.py
0

Namespace 継承のススメ

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

main.py
import argparse

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

args = parser.parse_args()
print(type(args))
python main.py foo
<class 'argparse.Namespace'>

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

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

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))
python main.py foo
<class '__main__.MyNamespace'>

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

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

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())
python main.py John Doe
John Doe

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

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())
python main.py John Doe
John Doe

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

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())
python main.py John Doe
John Doe
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

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

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

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)
python main.py 2,3
2,3

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

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)
python main.py 2 3
2,3
2
3

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

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)
python main.py 2,3
2,3
2
3
python main.py 2 3
2,3
2
3
python main.py 2, 3
2,3
2
3

結び

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

7
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?