チラシの裏からうっすら見える外枠の外のメモ書き

新聞に挟まってる硬い紙のチラシの裏からうっすら見える外枠の外に走り書きされたようなものです。思いついたときにふらふらと。

Pythonでtyping.Literalに入れたアノテーションをargparse.ArgumentParserのchoicesに渡す方法

Pythonのtype hintingが充実してきていますね。 今回は、typing.Literalコマンドライン引数の候補としても扱う方法を説明します。

docs.python.org

docs.python.org

TL; DR

typing.get_args()を使う。

はじめに

この記事では、typing.Literalを使うユースケースとしてホスト名とプロトコルのスキーム名を得ると、URIを生成するというプログラムを考えます。 プロトコルは簡単化のためにHTTPとHTTPS、そしてFTPのみを対応します。 なお、それぞれのプロトコルのスキーム名は、それぞれの名前を小文字にし、末尾に:をつけることで表現できます。

早速プログラムを作ってみました。

def generate_uri(hostname: str, scheme: Scheme) -> str:
  uri = scheme + "//" + hostname

  return uri

この関数の引数のうち、ホスト名hostnamestrを、スキーム名schemeは以下に定義したSchemeをtype hintingとしました。 これにより、スキーム名はSchemeに記載されたものだけが対応していると判断することができます。

import typing

Scheme = typing.Literal["http:", "https:", "ftp:"]

そのまま使う場合

この関数をコマンドラインからそのまま使ってみます。 そのためには、コマンドライン引数の設定を行う必要があります。 コマンドライン引数の設定には、argparse.ArgumentParserを使います。

import argparse
import sys

if __name__ == "__main__":
  parser = argparse.ArgumentParser()

  parser.add_argument("hostname", type=str, help="ホスト名")
  parser.add_argument("scheme", type=Scheme, help="スキーム名") # 注目

  args = parser.parse_args()

  # schemeがScheme型かバリデーションする
  if scheme not in Scheme:
    print("Scheme : " + scheme + " is not supported.")
    sys.exit(1)

  uri = generate_uri(hostname=args.hostname, scheme=args.scheme)

  print(uri)

この方法でスキーム名を指定すると、自分でバリデーションを行う必要があります。 parser.add_argument("scheme", type=Scheme, help="スキーム名")に注目してください。 このコードによって、コマンドライン引数のうち2つ目がスキーム名として受け付けられるよう設定されています。 しかし、type引数にSchemeを与えたとしてもコマンドラインのスキーム名が本当にScheme型なのかはわかりません。 なぜなら、Pythonのtype hintingはあくまでもヒントでしかなく、C言語などの静的型付け言語と異なり型指定の強制力がないからです。 そのため、schemeScheme型であるかをバリデーションしなければいけません。

argparse.ArgumentParserchoicesを使う場合

argparse.ArgumentParserにはバリデーションが働く機構が備わっています。 今回のスキーム名のように、いくつかの候補の中から選択する場合は、parser.add_argument()choices引数で候補を与えることができます。

  # 省略
  parser.add_argument("scheme", choices=["http:", "https:", "ftp:"], type=Scheme, help="スキーム名") # choicesを追加した
  # 省略

choices引数を設定することで、面倒なバリデーションをargparse.ArgumentParserに任せることができます。 そのため、スキーム名はhttp:https:ftp:のいずれかであることがargparse.ArgumentParserによって保証されます。

ただし、parser.add_argument()choiceslistしか受け付けないため、typing.Literalを与えることができません。 先程の例では、対応しているスキーム名をSchemeの定義とparser.add_argument()choices引数の2箇所に書くことになります。 これは大変手間で、DRY原則に違反した状態となっています。 ja.wikipedia.org

この状態を改善するため、parser.add_argument()choices引数にSchemeを与えたいですが、これはできません。

  # 省略
  parser.add_argument("scheme", choices=Scheme, type=Scheme, help="スキーム名")
  # 省略

なぜなら、parser.add_argument()choiceslistしか受け付けず、typing.LiteralであるSchemeを与えることができないためです。

typing.Literalのプロパティ

typing.Literalのプロパティを見ると、listとして振る舞えそうなプロパティがほとんどないことがわかります。

# これは別のプログラムです
import typing

Scheme = typing.Literal["http:", "https:", "ftp:"]

if __name__ == "__main__":
  print(dir(Scheme))
  # 実行結果は次のとおり
  # ['__args__', '__call__', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattr__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__instancecheck__', '__le__', '__lt__', '__module__', '__mro_entries__', '__ne__', '__new__', '__or__', '__origin__', '__parameters__', '__reduce__', '__reduce_ex__', '__repr__', '__ror__', '__setattr__', '__sizeof__', '__slots__', '__str__', '__subclasscheck__', '__subclasshook__', '__weakref__', '_getitem', '_inst', '_name', '_paramspec_tvars', '_typevar_types', 'copy_with']

そのため、list()を使ってtyping.Literallistに変換することはできません。

typing.get_args()typing.Literallistに変換する

typing.Literallistに変換するには、typing.get_args()を使います。 この関数は、引数に与えられたエイリアスに含まれる型をタプルとして返します。 例えば、dict[str, int]を与えると(str, int)を、typing.Literal["foo", "bar"]を与えると("foo", "bar")を得ることができます。 docs.python.org

typing.get_args()を使うことで、スキーム名のtype hintingをchoices引数に与えることができます。

  # 省略
  parser.add_argument("scheme", choices=typing.get_args(Scheme), type=Scheme, help="スキーム名")
  # 省略

choicesSchemeを指定することができたことで、DRY原則を保ちながらバリデーションをargparse.ArgumentParserに任せることができます。

おわりに

この記事では、Pythontyping.Literalに入れたアノテーションargparse.ArgumentParserchoicesに渡す方法を紹介しました。 typing.Literallistではないため、listしか許容しないchoicesに与えることができません。 そこで、typing.get_args()アノテーションに使ってtyping.Literallistへ変換し、choicesへ与えました。

typing.get_args()を使うことで、コード全体は次のようになります。

import argparse
import typing

Scheme = typing.Literal["http:", "https:", "ftp:"]

def generate_uri(hostname: str, scheme: Scheme) -> str:
  uri = scheme + "//" + hostname

  return uri

if __name__ == "__main__":
  parser = argparse.ArgumentParser()

  parser.add_argument("hostname", type=str, help="ホスト名")
  parser.add_argument("scheme", choices=typing.get_args(Scheme), type=Scheme, help="スキーム名")

  args = parser.parse_args()

  uri = generate_uri(hostname=args.hostname, scheme=args.scheme)

  print(uri)