先日、柴田さん(みんなのPython著者)のnote記事でargspecというPythonのCLIライブラリを知りました。
argparseの「引数を1つずつadd_argument()で追加する」という冗長さに対して、「型注釈で宣言的に書けばスッキリするよ」というライブラリなんですが…個人的には…
「宣言的って結局なにが生成されるかわからなくない?」
という疑問が出てきたので、実際にインストールして動かしながら検証してみました。
テスト環境
- WSL(Ubuntu24.04 LTS)
- Python 3.12.3
- argspec 0.4.1(
pip install argspec)
argspecの依存パッケージは typewire と typing-extensions だけで、合計サイズは約112KBとかなり軽量です。

- テスト環境
- まず「命令的」と「宣言的」って何🤔
- 同じCLIをargparseとargspecで書いて比較
- ここからが本題 — 「何が生成されるかわからない」問題
- 結局どっちがいいの?規模で判断が変わる?
- 引数が多すぎるなら設定ファイルに逃がすっていう手もあるのでは?
- おわりに
- 参考リンク
まず「命令的」と「宣言的」って何🤔
これがargparseとargspecの根本的な設計思想の違いなんですが、レストランに例えるとわかりやすいです。
命令的(argparse) = 手順を1つずつ指示する
「冷蔵庫からレタスを出して、洗って、ちぎって、皿に盛って、ドレッシングをかけて」
宣言的(argspec) = 欲しい結果だけ伝える
「シーザーサラダください」
どちらも同じサラダが出てきますが、How(どうやるか)を書くか、What(何が欲しいか)を書くかという違いになります。SQLも宣言的ですよね。SELECT * FROM users WHERE age >= 30 と書けばデータベースが勝手に最適な方法で取ってきてくれる。自分で全行ループしてフィルタリングする必要はない。
で、宣言的の方がコード量は減るんですが、「中で何が起きてるかわからない」というトレードオフがあるんじゃないかと。
同じCLIをargparseとargspecで書いて比較
んじゃってことで、ファイルバックアップツールを想定してCLI引数を両方で定義してみます。
argparse版(23行)
argparse_test.py
"""argparse版: ファイルバックアップツール想定""" import argparse from pathlib import Path parser = argparse.ArgumentParser(description="ファイルバックアップツール") parser.add_argument("sources", nargs="+", type=Path, help="バックアップ元ディレクトリ") parser.add_argument("-d", "--destination", type=Path, default=Path("/mnt/backup")) parser.add_argument("-S", "--max-size", type=float, default=None) parser.add_argument("-v", "--verbose", action="store_true") parser.add_argument("--compress", action="store_true", default=True) parser.add_argument("--no-compress", action="store_false", dest="compress") args = parser.parse_args() print(f"sources: {args.sources}") print(f"destination: {args.destination}") print(f"max_size: {args.max_size}") print(f"verbose: {args.verbose}") print(f"compress: {args.compress}")
argspec版(19行)
argspec_test.py
#!/usr/bin/env python3 """argspec版: ファイルバックアップツール想定""" from argspec import ArgSpec, positional, option, flag from pathlib import Path class Args(ArgSpec): sources: list[Path] = positional(help="バックアップ元ディレクトリ") destination: Path = option(Path("/mnt/backup"), short=True) max_size: float | None = option(None, aliases=("-S",)) verbose: bool = flag(short=True) compress: bool = flag(True) args = Args.from_argv() print(f"sources: {args.sources}") print(f"destination: {args.destination}") print(f"max_size: {args.max_size}") print(f"verbose: {args.verbose}") print(f"compress: {args.compress}")
今回のコードでは行数差は4行です。正直、この規模だとそこまで劇的な差ではないです。 ただ、argspec版は引数定義がクラスの中にまとまっているので、「このCLIが何を受け取るのか」は確かに見やすい😊
実行結果を比較してみる
まず同じ引数を指定して、実行してみます。
$ python argparse_test.py /data/proj1 /data/proj2 -d /backup -S 1024.0 -v --no-compress
sources: [PosixPath('/data/proj1'), PosixPath('/data/proj2')]
destination: /backup
max_size: 1024.0
verbose: True
compress: False
$ python argspec_test.py /data/proj1 /data/proj2 -d /backup -S 1024.0 -v --no-compress
sources: [PosixPath('/data/proj1'), PosixPath('/data/proj2')]
destination: /backup
max_size: 1024.0
verbose: True
compress: False
結果は完全に同じ。ここまでは問題なし。
ヘルプの出力を比較
argparse版のヘルプ
$ python argparse_test.py -h usage: argparse_test.py [-h] [-d DESTINATION] [-S MAX_SIZE] [-v] [--compress] [--no-compress] sources [sources ...] ファイルバックアップツール positional arguments: sources バックアップ元ディレクトリ options: -h, --help show this help message and exit -d DESTINATION, --destination DESTINATION -S MAX_SIZE, --max-size MAX_SIZE -v, --verbose --compress --no-compress

argspec版のヘルプ
$ python argspec_test.py -h
Usage:
argspec_test.py [OPTIONS] SOURCES [SOURCES...]
Options:
-h, --help
Print this message and exit
true: -v, --verbose
(default: False)
true: --compress
false: --no-compress
(default: True)
-d, --destination DESTINATION <Path>
(default: /mnt/backup)
-S, --max-size MAX_SIZE <float | None>
(default: None)
Arguments:
SOURCES <list[Path]>
バックアップ元ディレクトリ

argspec版の方が型情報(<Path>, <float | None>, <list[Path]>)やデフォルト値が表示されるので情報量は多いです。
面白いのは true: --compress / false: --no-compress の表示。どちらがONでどちらがOFFなのかが一目でわかるのは良い😊
ここからが本題 — 「何が生成されるかわからない」問題
【検証1】 count: int = option(10) で何が生成される?
argspec_test1.py
from argspec import ArgSpec, option class Args(ArgSpec): count: int = option(10) max_retry: int = option(3) output_dir: str = option("/tmp") args = Args.from_argv() print(args)
ヘルプを確認してみます。
$ python argspec_test1.py ---help
ArgumentError: Too many positional arguments: ---help
Usage:
argspec_test1.py [OPTIONS]
Options:
-h, --help
Print this message and exit
--count COUNT <int>
(default: 10)
--max-retry MAX_RETRY <int>
(default: 3)
--output-dir OUTPUT_DIR <str>
(default: /tmp)
Arguments:

count: int = option(10) からは --count が生成されます。ここまでは予想通り。
しかし、max_retry から --max-retry が生成されるのは暗黙のルールです。しかも実は --max_retry(アンダースコア版)も同時に使えます。
# どっちでも動く $ python argspec_test1.py --max-retry 5 Args(count=10, max_retry=5, output_dir='/tmp') $ python argspec_test1.py --max_retry 5 Args(count=10, max_retry=5, output_dir='/tmp') # イコール記法もOK $ python argspec_test1.py --count=50 Args(count=50, max_retry=3, output_dir='/tmp')

便利ではあるんですが、「フィールド名を書いただけで、ハイフン版とアンダースコア版の2つのオプションが暗黙的に生成される」というのは知らないと混乱しそうです。
【検証2】 short=True で頭文字が被ったらどうなる?
argspec_test2.py
from argspec import ArgSpec, flag class Args(ArgSpec): verbose: bool = flag(short=True) # -v version: bool = flag(short=True) # -v ← 衝突!
結果
$ python argspec_test2.py
Traceback (most recent call last):
File "/home/user2404/argspec-sample/argspec_test2.py", line 3, in <module>
class Args(ArgSpec):
File "/home/user2404/argspec-sample/.venv/lib/python3.12/site-packages/argspec/argspec.py", line 27, in __new__
cls.__argspec_schema__ = Schema.for_class(cls)
^^^^^^^^^^^^^^^^^^^^^
File "/home/user2404/argspec-sample/.venv/lib/python3.12/site-packages/argspec/parse.py", line 172, in for_class
raise ArgumentSpecError(f"Duplicate option alias: {short}")
argspec.errors.ArgumentSpecError: Duplicate option alias: -v

⚠️クラス定義時にエラーになります。 これはちゃんと検出してくれるので安心。
ただ、エラーが出るのは実行時なので、テストを書いておかないと本番で初めて気づく…ということになりかねません。
【検証3】 flag(True) と flag(False) でnegatorの挙動が変わる
これが一番わかりにくかったところです。
argspec_test3.py
from argspec import ArgSpec, flag class Args(ArgSpec): compress: bool = flag(True) # デフォルトTrue debug: bool = flag(False) # デフォルトFalse quiet: bool = flag() # デフォルトなし(= False) args = Args.from_argv() print(args)
ヘルプ表示の実行結果を見ると
$ python argspec_test3.py --help
Usage:
argspec_test3.py [OPTIONS]
Options:
-h, --help
Print this message and exit
true: --compress
false: --no-compress
(default: True)
true: --debug
(default: False)
true: --quiet
(default: False)
Arguments:

flag(True) のときだけ --no-xxx が自動生成される。 flag(False) や flag() では生成されません。
実際に --no-debug を使おうとすると:
$ python argspec_test3.py --no-debug ArgumentError: Too many positional arguments: --no-debug

エラーメッセージも知らないオプションですではなく位置引数が多すぎますなので、何が起きてるかわかりにくい…🙄
このルールをまとめると…
| 定義 | デフォルト値 | --no-xxx 生成 |
|---|---|---|
flag(True) |
True |
自動生成 |
flag(False) |
False |
なし |
flag() |
False |
なし |
argparseなら --no-compress は自分で書くので「あるかないか」は一目瞭然。argspecだと「デフォルト値が何かによってnegator(否定オプション)の存在が変わる」ので、このルールを知らないと事故るかも🤔
暗黙の挙動を確認する方法
とはいえ、宣言的ツールには「中身を確認する手段」がセットであるのが普通かなと思います。
argspecの場合は、ヘルプ出力がそのまま確認手段になります:
argspec_help.py
"""Args.__argspec_schema__.help() の使い方""" from argspec import ArgSpec, positional, option, flag from pathlib import Path class Args(ArgSpec): sources: list[Path] = positional(help="バックアップ元ディレクトリ") destination: Path = option(Path("/mnt/backup"), short=True) max_size: float | None = option(None, aliases=("-S",)) verbose: bool = flag(short=True) compress: bool = flag(True) # from_argv() を呼ばなくても、クラス定義だけでヘルプ文字列を取得できる help_text = Args.__argspec_schema__.help() print(help_text)

これで自動生成されたオプション名、negator(否定オプション)、型情報がすべて確認できます。不安なときはこれを見ればOK。
結局どっちがいいの?規模で判断が変わる?
使ってみた感想として、面倒さの種類が違う というのが正直なところです。
| 引数の数 | argparseの辛み | argspecの辛み | 個人的な推奨 |
|---|---|---|---|
| 3〜5個 | 低い。普通に書ける | 暗黙ルール把握コストの方が大きい | argparse |
| 5〜10個 | 定義が長くなり始める | 宣言的な恩恵が出始める | どちらでも |
| 10個超 | 定義コードが本体を圧迫する | 型一覧で見通しが良くなる | argspec |
引数が3〜5個の小さなスクリプトなら、argparseで全然いい。暗黙の挙動もないし、何が起きてるか全部見える安心感がある。
argspecの恩恵が出るのは引数が10個を超えてくるあたり。「定義コードの冗長さ」による管理コストが、「暗黙の挙動を把握するコスト」を上回るようになるラインでしょうね。
引数が多すぎるなら設定ファイルに逃がすっていう手もあるのでは?
これは書いてて思ったんですが、今回のargparse vs argspecは「CLI引数をどう定義するか」の議論なんですよね。
でも引数が10個超えて管理が辛いなら、そもそもCLI引数で全部渡すのをやめる方が根本的な解決になるかも🤔
argspec_toml.py
import argparse import tomllib from pathlib import Path parser = argparse.ArgumentParser() parser.add_argument("--config", type=Path, default="config.toml") parser.add_argument("-v", "--verbose", action="store_true") args = parser.parse_args() with open(args.config, "rb") as f: config = tomllib.load(f) print(f"設定ファイル: {args.config}") print(f"verbose: {args.verbose}") print(f"config: {config}")
# config.toml [backup] sources = ["/data/project1", "/data/project2"] destination = "/mnt/backup" max_size = 1024.0 compress = true
実行結果
$ python argspec_toml.py
設定ファイル: config.toml
verbose: False
config: {'backup': {'sources': ['/data/project1', '/data/project2'], 'destination': '/mnt/backup', 'max_size': 1024.0, 'compress': True}}

このように設定ファイルすることで、全体像が一目で見渡せるし、git管理できるし、環境ごとに切り替えもできる。
ライブラリの選択より、「何をCLI引数にし、何を設定ファイルにするか」という設計判断 の方が実際の管理コストへの影響はあるなと感じました。
おわりに
| 観点 | argparse | argspec |
|---|---|---|
| 明示性 | ◎ 全部自分で書くので把握しやすい | △ 暗黙のルールを覚える必要がある |
| 簡潔さ | △ 引数が増えると冗長 | ◎ 型注釈で宣言的に書ける |
| IDE補完 | △ Namespaceなので効きにくい | ◎ dataclass的なので補完が効く |
| 学習コスト | ○ 資料豊富。枯れた標準ライブラリ | △ 新しい。暗黙ルールの把握が必要 |
| サブコマンド | ◎ 対応 | ✗ 非対応 |
| 依存 | ◎ 標準ライブラリ | ○ 軽量(約112KB) |
argspecは「宣言的で簡潔」という良さはあるけど、「何が生成されるかわからない」という宣言的スタイル共通の課題もそのまま持っています。
触ってみて感じたのは、単純な「命令的 vs 宣言的」の話ではないのかもしれない、ということです。
- argparseはパーサーを
組み立てるツール - argspecは仕様を
型として固定するツール
前者は細かく制御できる代わりに仕様がコードに分散しやすく、後者は仕様がクラス構造としてまとまる代わりに暗黙ルールを受け入れることになります。
つまり、後者のような宣言的ツールはその抽象度に価値を感じるかどうかで好みが分かれそう。
「宣言的=良い」という単純な話ではなく、両方のスタイルを場面に応じて使い分ける判断力の方が大事 というのが、実際に触ってみた感想です。
さらに言えば、引数が本当に多いなら設定ファイル(TOML/YAML)に逃がすという選択肢もあるかもな~ってことで。
どちらを使うのが正解というわけではないので、適した場面を見つけるしかないのかもしれません。