docstrings から argparse を補完する

Posted: , Modified:   Python DocString argparse Qiita

本稿は Qiita 投稿記事 のバックアップです.

はじめに

python でコマンドラインツールを作る場合,引数のパーサーには click が便利. しかし,複雑なことをやろうとすると argparse の方が簡単な気がして使い分けている. 一方で,argparse を使うと description や help をサブコマンドや引数に与える必要がある. これらは,大体の場合 docstring と情報が重複していて二度手間である. そこで,docstring を使って argparse に情報を渡す方法を考えてみる.

なお,docstrings は Googleのスタイルガイドに従って書かれているとする.

プログラム全体の description

コマンド本体の説明,つまり argparse.ArgumentParser の description 引数. これは main 関数のあるモジュールの docstring に書かれているはずである. よって,ArgumentParser の作成は,

parser = argparse.ArgumentParser(
  description=__doc__, formatter_class=argparse.RawTextHelpFormatter)

とすれば良さそうである. なお,formatter_class に argparse.RawTextHelpFormatter を渡しているのは, これ以降設定する description や help から改行や空白を削除されるのを防ぐためである.

サブコマンドの description

サブコマンドは,それを実行する関数とセットになっていると仮定する. つまり最低限,

a1_cmd = subparsers.add_parser("a1")
a1_cmd.set_defaults(cmd=a1)

となっていると思う.であれば,サブコマンドを実装する関数,ここでは a1 が与えられれば,

a1_cmd = subparsers.add_parser(
  a1.__name__, help=a1.__doc__, description=a1.__doc__)
a1_cmd.set_defaults(cmd=a1)

などとできるはず. ただし,このままでは help や description に引数情報も含めた長文がセットされてしまうので, docstring の一行目だけを取得する headline 関数と, 引数の説明に入るまで(Googleスタイルだと Args: Returns: Raises: Yields: が現れるまで) を取得する description 関数を用意して,

a1_cmd = subparsers.add_parser(
  a1.__name__,
  help=headline(a1.__doc__),
  description=description(a1.__doc__))
a1_cmd.set_defaults(cmd=a1)

とすることにする.(Yields はないと思うけど一応) headline 関数と description 関数は次のような感じ.

def headline(text):
    """ Returns a head line of a given text.
    """
    return text.split("\n")[0]

keywords =("Args:", "Returns:", "Raises:", "Yields:")
def checker(v):
    """ Check a given value not starts with keywords.
    """
    for k in keywords:
        if k in v:
            return False
    return True

def description(text):
    """ Returns a text before `Args:`, `Returns:`, `Raises:`, or `Yields:`.
    """
    lines = list(itertools.takewhile(checker, text.split("\n")))
    if len(lines) < 2:
        return lines[0]
    return "{0}\n\n{1}".format(lines[0], textwrap.dedent("\n".join(lines[2:])))

各引数の help

a1_cmd.add_argument(“name”, help=“description of this argument.") などとする場合の help 引数. これらはサブコマンドに対応する関数の docstring に書かれていると思われる. Googleスタイルだと Args: 以降に辞書形式に近い形で書かれているはずである. よって,docstring から引数の説明欄を入手しておいて, 対応する引数が add_argument で追加されたときに help 引数として渡してやれば良い.

上の headline, description に加えて,もっと細かく docstring を解析する必要があるので, まとめて関数を用意する.

_KEYWORDS_ARGS = ("Args:",)
_KEYWORDS_OTHERS = ("Returns:", "Raises:", "Yields:")
_KEYWORDS = _KEYWORDS_ARGS + _KEYWORDS_OTHERS

def checker(keywords):
    """Generate a checker which tests a given value not starts with keywords."""
    def _(v):
        """Check a given value matches to keywords."""
        for k in keywords:
            if k in v:
                return False
        return True
    return _

def parse_doc(doc):
    """Parse a docstring.
    Parse a docstring and extract three components; headline, description,
    and map of arguments to help texts.
    Args:
      doc: docstring.
    Returns:
      a dictionary.
    """
    lines = doc.split("\n")
    descriptions = list(itertools.takewhile(checker(_KEYWORDS), lines))

    if len(descriptions) < 3:
        description = lines[0]
    else:
        description = "{0}\n\n{1}".format(
            lines[0], textwrap.dedent("\n".join(descriptions[2:])))

    args = list(itertools.takewhile(
        _checker(_KEYWORDS_OTHERS),
        itertools.dropwhile(_checker(_KEYWORDS_ARGS), lines)))
    argmap = {}
    if len(args) > 1:
        for pair in args[1:]:
            kv = [v.strip() for v in pair.split(":")]
            if len(kv) >= 2:
                argmap[kv[0]] = kv[1]

    return dict(headline=descriptions[0], description=description, args=argmap)

この parse_doc に docstring を渡すと,headline, description, そして args 辞書が返ってくる. 前者二つは,

a1_doc = parse_doc(a1)
a1_cmd = subparsers.add_parser(
  a1.__name__,
  help=a1_doc["headline"],
  description=a1_doc["description"])
a1_cmd.set_defaults(cmd=a1)

のように使え,引数の辞書は,

a1_cmd.add_argument("name", help=a1_doc["args"]["name"])

のように使える.

まとめとライブラリ

以上のようにすれば,description や help を毎回書かなくても,docstrings から補完することができる. とはいえ,コマンドラインアプリを書くたびにこれらを用意するのも手間なので, ライブラリ dsargparse を用意した. 上記の内容を argparse のラッパとして実現しているので,利用者側からはほぼ何も書かなくて良くなる. サンプル参照