ここ最近、Pythonのモジュール例外の表示を分かりやすくする方法について悩んでいたのですが、一番良さそうな方法が見つかったので、まとめてみました。
まずはモジュール例外を定義する一般的な方法をおさらいします。
一般的な方法
ここでは具体的な状況として、Python 3.3で、urllib83.urlopen()という関数からなる、urllib83モジュールを書くという状況を考えてみます。
urllib83は、83番目のurllibモジュールという意味ですが、もし将来的に同名の標準モジュールが登場したらすみません。
それはさておき、このモジュールは下記の3ファイルで構成されていることにします。
urllib83/__init__.py
ftp.py
http.py
urllib83.urlopen()を思いっきり手抜きして書くと、こんな感じになるはずですね。
from .ftp import ftpopen
from .http import httpopen
def urlopen(url):
if url.startswith("ftp://"):
return ftpopen(url)
else:
return httpopen(url)
次に、このモジュールにはurllib83.FTPErrorとurllib83.HTTPErrorという例外クラスが存在し、どちらのクラスもurllib83.Urllib83Errorというベース例外クラスを継承しているということにします。
これら3つの例外クラスを素直に実装すると、urllib83/__init__.py内で、
class Urllib83Error(Exception):
pass
class FTPError(Urllib83Error):
pass
class HTTPError(Urllib83Error):
pass
のように定義することになると思いますが、このやり方には一つ問題があります。
urllib83.FTPErrorは、当然urllib83/ftp.py内でraiseされますので、ftp.pyはFTPErrorを、
from . import FTPError
def ftpopen(url):
...
raise FTPError
のようにimportして利用しますが、前述したように__init__.pyはftp.pyからftpopen()をimportしています。
このままでは、__init__.pyとftp.pyがお互いをimportして、循環インポートという問題が発生してしまうのです。
そこで、一般的にはこの問題を避けるためにurllib83/errors.pyというファイルを追加して、下記のような内容にします。
class Urllib83Error(Exception):
pass
class FTPError(Urllib83Error):
pass
class HTTPError(Urllib83Error):
pass
errors.py以外のファイルはそれぞれ、
py3:__init__.py
from .errors import Urllib83Error, FTPError, HTTPError
all = ['Urllib83Error', 'FTPError', 'HTTPError']
のように必要な例外クラスをインポートします。(注:`__all__`の行が必要なのは、`__init__.py`だけです。)
`__init__.py`も例外クラスをimportしていますので、urllib83モジュールを利用する側も、
```py3:client.py
from urllib83 import urlopen, Urllib83Error
try:
urlopen(url)
except Urllib83Error:
pass
のようにモジュール例外を使えます。
ここまでは何も問題ありません。
何が問題なのか?
私が気になったのは、urllib83モジュールを使う側がtry...except文を使わずに、urllib83.urlopen()
を呼び出した場合です。
urllib83.FTPErrorが発生すると、トレースバックが下記のように表示されます。
>>> import urllib83
>>> urllib83.urlopen("ftp://")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "./urllib83/__init__.py", line 10, in urlopen
return ftpopen(url)
File "./urllib83/ftp.py", line 4, in ftpopen
raise FTPError
urllib83.errors.FTPError
>>>
最後の行でurllib83.FTPErrorではなく、urllib83.errors.FTPErrorと表示されていますね。
これは問題であり、urllib83.FTPErrorと表示されるべきだというのが私の意見です。
しかし、Pythonistaの方々の一般的な見解では、「この表示のどこに問題が存在するのかさっぱり分からない」ということになるようです。その理由としては、
urllib83モジュールのドキュメントには、
urllib83.FTPErrorが発生すると記載されているだろうし、利用者がexcept句に、except urllib83.FTPError:と書けば発生した例外を補足できる。
ドキュメントに書かれている仕様の通りに動作するのだから、問題はどこにも存在しない
とのことでした。一方、それに対する私の反論は、
urllib83モジュールのユーザーが、
urllib83.errors.FTPErrorという表示に実際に遭遇した時に、それがurllib83モジュールのドキュメントに記載されたurllib83.FTPErrorであることを知るためのコストが高すぎるのではないか?
最初から、urllib83.FTPErrorと表示されたほうが分かりやすいし、ユーザーに親切なので、何とかすべきである
というものです。
解決方法
urllib83.FTPErrorと表示されるようにする方法を色々試してみたのですが、結局errors.pyで例外クラスを
_SAVED_NAME = __name__
__name__ = __package__
# __name__ = "urllib83" # Python 3.2 まではこちらで
class Urllib83Error(Exception):
pass
class FTPError(Urllib83Error):
pass
class HTTPError(Urllib83Error):
pass
__name__ = _SAVED_NAME
のように定義するのが一番良さそうだという結論になりました。
ポイントは、__name__属性の内容を一時的に書き換えて、例外クラスの定義が終わったら元に戻すことです。
例外が発生すると以下のように表示されます。
>>> import urllib83
>>> urllib83.urlopen("ftp://")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "./urllib83/__init__.py", line 10, in urlopen
return ftpopen(url)
File "./urllib83/ftp.py", line 4, in ftpopen
raise FTPError
urllib83.FTPError
>>>
ちゃんとurllib83.FTPErrorと表示されていますね。
__name__属性の書き換えは一般的ではないですし、複雑なコードを書くと、urllib83/__init__.py 内で定義した時と比べて違いが出てくることもありますので、__name__属性を書き換えている間は込み入った処理をできるだけ避けることを強くオススメします。
それから、__name__属性を元に戻すのを忘れないようにしてください。
最後に
今のところ、この方法で特に問題は見つかっていませんが、もし何か問題点に気がついた方は、ぜひコメント等でお知らせください。
個人的には、統合開発環境で何か問題が発生するのではないか?という点を特に心配しています。