Motivation
Pythonのコマンドライン引数処理はargparseモジュールが優秀で大抵は事足りるのだけれど、rsyncの--include
と--exclude
のような複数回指定できて、かつ書かれた順番も取得したいオプションを実装したくなった。
例えば与えられた文字列の集合をワイルドカードを使ったフィルターで取捨選択する例を考える。
Aaron
Abel
Adam
Adolph
Adrian
Alan
Albert
...
この時、--include "A*" --exclude "?d*" --include "*m"
というオプションが与えられたら次のような処理をしたい。
-
A
から始まる文字列を対象にする -
A
から始まる文字列でも2文字目がd
な文字列は対象外にする - さらにその対象外にされた文字列の中で
m
で終わる文字列は対象にする
※ rsyncの挙動とは異なります
そうすると、--include
と--exclude
オプションは複数回出現することが許されなければならない上に書かれた順番も取得する必要がある。前者についてはadd_argument
のaction
に"append"
を指定すれば実現できるが、与えられた引数はオプションごとにまとめられてしまうため、--include
と--exclude
がどのような順序で指定されたかという情報は失われてしまう。
Solution
action
を自分で定義して、同じ属性にラベル付きで格納する。
argparse.Action
についてはこちら。
import argparse
def make_multistate_append_action(key):
class _MultistateAppendAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
args = getattr(namespace, self.dest)
args = [] if args is None else args
args.append((key, values))
setattr(namespace, self.dest, args)
return _MultistateAppendAction
この関数は、与えられたコマンドライン引数をkey
と共にリストに格納するactionを返す。
parser = argparse.ArgumentParser()
parser.add_argument("inputfile")
parser.add_argument("-i", "--include", nargs='+', dest="filters",
action=make_multistate_append_action(True))
parser.add_argument("-e", "--exclude", nargs='+', dest="filters",
action=make_multistate_append_action(False))
args = parser.parse_args()
ポイントは--include
と--exclude
のdest
に同じ値を設定すること。意外にもArgumentParserはdest
が重複するオプションを追加してもエラーにはならない。
この状態でargs
を確認するとこんな感じ。
$ python3 test.py names.txt --include "A*" --exclude "?d*" --include "*m"
Namespace(filters=[(True, ['A*']), (False, ['?d*']), (True, ['*m'])], inputfile='names.txt')
Example
フィルターまで実装してみるとこんな感じ。re
を使うほどでもないけどワイルドカードは扱いたいという時にはfnmatchモジュールがとても便利。
この実装だと重複のあるリストにはそのまま使えないのでご勘弁。
import argparse
import fnmatch
from itertools import groupby, chain
def make_multistate_append_action(key):
class _MultistateAppendAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
args = getattr(namespace, self.dest)
args = [] if args is None else args
args.append((key, values))
setattr(namespace, self.dest, args)
return _MultistateAppendAction
def map_filters(strings, filters):
strings = set(strings)
if filters is None:
return strings
include_strings = set()
for to_include, values in groupby(filters, key=lambda f: f[0]):
patterns = set(chain(*(f[1] for f in values)))
filtered_strings = set.union(*(set(fnmatch.filter(strings, p)) for p in patterns))
if not to_include:
include_strings |= strings - filtered_strings
strings = filtered_strings
if to_include:
include_strings |= strings
return include_strings
def _main():
parser = argparse.ArgumentParser()
parser.add_argument("inputfile")
parser.add_argument("-i", "--include", nargs='+', dest="filters",
action=make_multistate_append_action(True))
parser.add_argument("-e", "--exclude", nargs='+', dest="filters",
action=make_multistate_append_action(False))
args = parser.parse_args()
with open(args.inputfile) as f:
strings = [l.strip() for l in f]
for txt in map_filters(strings, args.filters):
print(txt)
if __name__ == "__main__":
_main()
$ python3 filter.py names.txt --include "A*" --exclude "?d*" --include "*m"
Abel
Aaron
Albert
Adam
Alan