はじめに
#みんなでアイうた、最高の時間になりました!ありがとうございました🙌 nikkieです。
この1月に出た書籍『Pythonエンジニア育成推進協会監修 Python実践レシピ』(以下、『Python実践レシピ』)を読んでいます。
先日、Python標準ライブラリのargparse
について、「位置引数もある!」と書籍での説明を補足しましたが、今回は別の点の補足です。
『Python実践レシピ』でargparse.FileType
を知りました!
「もっと詳しく知りたい!」とドキュメントを調べたところ、「書籍の書き方だけでは情報が十分に伝わっていないかも」という懸念を抱きました。
そこで、「この記事が補完関係になったらいいな」と、いつものおせっかいで勝手に補足しちゃいます!
目次
- はじめに
- 目次
- この記事の要点
- 動作環境
- 『Python実践レシピ』よりargparse.FileTypeの紹介
- argparse.FileTypeのドキュメントより
- 脱線:開いたファイルが閉じられないと何が問題なの?
- IMO:別のやり方として、type引数に関数を渡す
- 終わりに
- 補足:open関数のencoding引数指定
この記事の要点
- 『Python実践レシピ』では
add_argument
のtype
引数に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さんです)。
これだけでも一ネタなので、機会があれば記事としてアウトプットします。
-
ファイルを閉じるについては、私の性格(ちゃんとやりたい)として、Python入門時から徹底しています。 ref: https://pycamp.pycon.jp/textbook/5_module.html↩
-
用語集 https://docs.python.org/ja/3/glossary.html#term-file-object↩
-
https://docs.python.org/ja/3/library/io.html#io.IOBase.__del__↩
-
https://docs.python.org/ja/3/reference/datamodel.html#object.__del__↩
-
(原文) 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↩
-
(原文) For example IronPython, PyPy, and Jython don’t use reference counting and therefore won’t close the file at the end of the loop.↩
-
(原文) It’s bad practice to rely on CPython’s garbage collection implementation because it makes your code less portable.↩
-
(原文) 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.↩
-
https://peps.python.org/pep-0597/ encoding引数の指定が省略されているコードを、Windowsユーザはうまく動かせないと指摘しています(読み込むファイルにASCII外の文字が入っている場合です)↩