nikkie-ftnextの日記

イベントレポートや読書メモを発信

argparseのadd_argumentのtype引数には、FileTypeよりもユーザ定義関数を渡すのをオススメしたいです!(#Python実践レシピ を勝手に補足)

はじめに

#みんなでアイうた、最高の時間になりました!ありがとうございました🙌 nikkieです。

この1月に出た書籍『Pythonエンジニア育成推進協会監修 Python実践レシピ』(以下、『Python実践レシピ』)を読んでいます。
先日、Python標準ライブラリのargparseについて、「位置引数もある!」と書籍での説明を補足しましたが、今回は別の点の補足です。

Python実践レシピ』でargparse.FileTypeを知りました!
「もっと詳しく知りたい!」とドキュメントを調べたところ、「書籍の書き方だけでは情報が十分に伝わっていないかも」という懸念を抱きました。
そこで、「この記事が補完関係になったらいいな」と、いつものおせっかいで勝手に補足しちゃいます!

目次

この記事の要点

  • Python実践レシピ』ではadd_argumenttype引数にargparse.FileTypeを渡して、エラーメッセージを分かりやすくするtipsを紹介
  • argparse.FileTypeのドキュメントを当たったところ、引数で処理が失敗した場合、ファイルを閉じないということが分かった
  • ファイルを閉じないのを好まない立場なので、type引数にユーザ定義関数を渡してエラーメッセージを分かりやすくする方法を共有します

動作環境

$ python -VV
Python 3.10.2 (v3.10.2:a58ebcc701, Jan 13 2022, 14:50:16) [Clang 13.0.0 (clang-1300.0.29.30)]

Python実践レシピ』よりargparse.FileTypeの紹介

10.4.4「argparse:よくあるエラーと対処法」でargparse.FileTypeが紹介されます。

「ユーザーが何をすべきかわかりにくくなって」いる例

read_file.py

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("input")
args = parser.parse_args()

with open(args.input, encoding="utf8") as f:
    print(f.read().rstrip())

実装について書籍から以下のように変えています:

  • コマンドラインから渡す引数が1つだけなので、位置引数で実装しました。詳しくは
  • open()関数にencoding引数を指定しました(補足を後述します)
  • ファイルオブジェクトの内容(read()の返り値)の末尾の改行文字を削除するためにrstrip()を追加しました

読み取るファイルを作ります。

$ echo "アイの歌声を聴かせて" > some.txt

読み取らせてみましょう。

$ python read_file.py some.txt
アイの歌声を聴かせて

いいですね!

Python実践レシピ』には、存在しないファイルを指定したとき、ユーザが何をすべきか分かりにくいと書かれています。
エラーメッセージを確認しましょう。

$ python read_file.py spam.txt
Traceback (most recent call last):
  File "/.../read_file.py", line 7, in <module>
    with open(args.input, encoding="utf8") as f:
FileNotFoundError: [Errno 2] No such file or directory: 'spam.txt'

第1引数に渡したspam.txtについて「No such file or directory」とFileNotFoundErrorが送出されました。

このスクリプトを開発した人なら、Tracebackを見て「存在しないファイルをopenしようとしたのか」と気付き、修正できると思います。
このスクリプトが配布されていて、中身を知らずに使おうとした人にとって、何をすべきか分かりにくいということなのかなと理解しました(Tracebackを目にすることになりますからね)。

では、この解決の仕方を見ていきましょう。

argparse.FileTypeにより、ユーザーが何をすべきか分かりやすくできる

argparse.FileTypeを使って以下のように書き換えます。

import argparse

parser = argparse.ArgumentParser()
parser.add_argument("input", type=argparse.FileType("r", encoding="utf8"))  # 変更1
args = parser.parse_args()

print(args.input.read().rstrip())  # 変更2

変更点は2箇所です:

  • add_argumentメソッドでtype引数にFileTypeオブジェクトを渡しました
    • 後で示すドキュメントから、FileTypeの引数の指定はopen()関数と同様と理解しています
  • コマンドライン引数で指定したパスのファイルが開かれ、args.inputはファイルオブジェクトなので、そのままread()しています

存在しないファイルを指定したときのエラーメッセージを確認しましょう。

$ python read_file.py spam.txt
usage: read_file.py [-h] input
read_file.py: error: argument input: can't open 'spam.txt': [Errno 2] No such file or directory: 'spam.txt'

たしかに、スクリプトの中身を知らずに使いたい人にとっては分かりやすいと言えると思います!

  • Tracebackを目にしなくて済む
  • 使い方(usage)が表示されている
  • inputに指定したファイルが「No such file or directory」で開けなかったと分かる

argparse.FileTypeのドキュメントより

FileTypeは『Python実践レシピ』で初めて知ったので、ドキュメントも確認しました(これは私の習慣によるものです)。

標準入力も扱える!

https://docs.python.org/ja/3/library/argparse.html#filetype-objects に興味深い記載がありました。

FileType オブジェクトは擬似引数 '-' を識別し、読み込み用の FileType であれば sys.stdin を、(略)変換します:

試してみましょう!

$ python read_file.py -
みんなでアイうた  # この行を入力して改行した後に、Ctrl+D
みんなでアイうた

Ctrl+Dをするまで標準入力から読み取り続けます(1行入力して改行した後、Ctrl+Dしました)

標準入力から複数行入力した場合

$ python read_file.py -
$ python read_file.py -
サトミ
シオン  # この行を入力して改行した後に、Ctrl+D
サトミ
シオン

制限がある:開かれたファイルは自動では閉じない!?

ドキュメントを読み進めていくと「ムム😕」という記載を見つけました。

https://docs.python.org/ja/3/library/argparse.html#type

また、type キーワードに FileType を指定した場合には制限があります。ある引数に FileType を指定してファイルが開かれ、その後ろのどこかの引数で処理が失敗した場合、エラーが表示されますが、開かれたファイルは自動では close されません。

「開かれたファイルが自動でcloseされない」なんて!

上の引用は次のように続きます。

これを好まない場合は、parser による引数の処理が終わるまで待ち、その後に with 文などでファイルを開くのがよいでしょう。

これは好みの問題だと思いますが、add_argumentメソッドのtype引数にFileTypeオブジェクトを指定し、引数のパースエラーとなった時、開かれたファイルが自動で閉じない1のは好ましくないな(私だったら他の人に使用をオススメしないかな)と思いました。

脱線:開いたファイルが閉じられないと何が問題なの?

Python入門時からopenしたファイルはcloseすべし(closeを都度書くのが億劫ならwith文を使うべし)の教えを守ってきたので、「closeしないと落ち着かない」という感覚なのですが、この機に少し調べてみました。

openが返すファイルオブジェクト2は、__del__の呼び出しでclose()メソッドが呼ばれる3そうです。
プログラマが明示的にcloseを呼び出さなくても、プログラムを実行するインタプリタが終了するときに、__del__が呼ばれているのかな」と思ったのですが、ドキュメント4によると

インタプリタが終了したときに、残存しているオブジェクトの __del__() メソッドが呼び出される保証はありません。

とのこと。
つまり、プログラマが明示的にclose呼び出す必要があるんだなと理解しました。

closeしないと何が問題なの?」と誰かが聞いているだろうと調べたところ、以下を見つけました。

回答を読むと(※意訳です)、

  • CPythonは参照カウントというガベージコレクションの仕組みにより、closeメソッドを呼び出さなくても閉じられる5
  • CPython以外のPythonの実装では、参照カウントを使っておらず、closeメソッドを呼び出さなければファイルを閉じない6
  • CPythonの実装の詳細に依存しており、(Cpython以外の処理系への)可搬性が損なわれている7

IMO:別のやり方として、type引数に関数を渡す

私の中では(好みとして)FileTypeは使わないと考えていますが、このままではFileTypeを使って解決したかった「ユーザーにとって、エラーメッセージが何をすべきか分かりにくい」という問題が残っています。

過去の私のアウトプット8から、type引数に独自定義した関数を渡した解決法を紹介します(スライド28・29)。

import argparse
from pathlib import Path


def existing_path(path_str: str) -> Path:
    """文字列が指すファイルが存在すれば、そのファイルを指すPathオブジェクトを返す
    
    存在しなければ、ArgumentTypeErrorを送出する
    """
    path = Path(path_str)
    if not path.exists():
        message = f"{path_str}: No such file or directory"
        raise argparse.ArgumentTypeError(message)
    return path


parser = argparse.ArgumentParser()
parser.add_argument("input", type=existing_path)
args = parser.parse_args()

with args.input.open(encoding="utf8") as f:  # Path.open()
    print(f.read().rstrip())

存在しないファイルのパスをコマンドライン引数として渡してみましょう。

$ python read_file.py spam.txt
usage: read_file.py [-h] input
read_file.py: error: argument input: spam.txt: No such file or directory

FileTypeを使ったときと同様のエラーメッセージが表示できていますね!

  • Tracebackを目にしなくて済む
  • 使い方(usage)が表示されている
  • inputに指定したファイルが「No such file or directory」となったと分かる(文言は調整できると思います)
  • ただし、-で標準入力は受け取れなくなっています😢

type引数のドキュメントの以下を参考に実装しています。
https://docs.python.org/ja/3/library/argparse.html#type

type キーワードの引数として、単一の文字列を受け取るような任意の呼び出しオブジェクト (callable) が使用できます。もし呼び出しが ArgumentTypeError, TypeError, または ValueError 型の例外を送出した場合は、parser がそれをキャッチして適切なエラーメッセージが表示されます。それ以外の型の例外は処理されません。

ユーザが定義した関数も使用できます

ドキュメントには以下のように続いています。

一般論として、type キーワードに指定するものは、せいぜい上記の3種類の例外を発生するくらいの、お手軽な変換に限るべきです。より複雑な (interesting) エラー処理、またはリソース管理を伴うものは、引数を解析したあとに別個の処理として行うべきです。

終わりに

Python実践レシピ』で紹介されたargparse.FileTypeについて、

  • 引数で処理が失敗した場合、ファイルを閉じない挙動
  • 私の好みとしてファイルが閉じないのは落ち着かないので、type引数にユーザ定義関数を渡してエラーメッセージをカスタマイズしたい

の2点を共有しました。

限られた紙面の中で、豊富なライブラリを紹介するというスタンスの『Python実践レシピ』👏
認定試験の教科書でもあるため、多くの方が読むと期待されるこの本を、引き続き勝手に補足していきます。

補足:open関数のencoding引数指定

Python3.10で追加されたEncodingWarning(オプショナル)を知ってから、私は指定するようにしています。

https://docs.python.org/ja/3/whatsnew/3.10.html#optional-encodingwarning-and-encoding-locale-option

(意訳)ほとんどのUnixプラットフォームではUTF-8が使われますが、UTF-8のファイルを開くときにencoding引数を省略するのは、非常によくあるバグです9

What's New内の記載は簡潔ですが、これを提案したPEP10を読むと「指定したほうがいいな」と思いました(提案者はInadaさんです)。
これだけでも一ネタなので、機会があれば記事としてアウトプットします。


  1. ファイルを閉じるについては、私の性格(ちゃんとやりたい)として、Python入門時から徹底しています。 ref: https://pycamp.pycon.jp/textbook/5_module.html

  2. 用語集 https://docs.python.org/ja/3/glossary.html#term-file-object

  3. https://docs.python.org/ja/3/library/io.html#io.IOBase.__del__

  4. https://docs.python.org/ja/3/reference/datamodel.html#object.__del__

  5. (原文) In current versions of CPython the file will be closed at the end of the for loop because CPython uses reference counting as its primary garbage collection mechanism

  6. (原文) For example IronPython, PyPy, and Jython don’t use reference counting and therefore won’t close the file at the end of the loop.

  7. (原文) It’s bad practice to rely on CPython’s garbage collection implementation because it makes your code less portable.

  8. 近況報告:2020夏、登壇の夏、予定していた全公演を駆け抜けました!🎤 - nikkie-ftnextの日記

  9. (原文) Since UTF-8 is used on most Unix platforms, omitting encoding option when opening UTF-8 files (e.g. JSON, YAML, TOML, Markdown) is a very common bug.

  10. https://peps.python.org/pep-0597/ encoding引数の指定が省略されているコードを、Windowsユーザはうまく動かせないと指摘しています(読み込むファイルにASCII外の文字が入っている場合です)