🐍

自分が触れるコード中の親クラスのファイルパスを列挙する

2022/01/19に公開

親クラスのファイルのパスのリストが欲しい

Pythonを書いているときに、あるクラスHogeを継承しているクラスFugaと、Fugaをさらに継承しているPiyoについて、それらのクラス定義が存在しているファイルのパスが欲しくなった(キャッシュ機構の実装中に)。

例えば、以下のようなクラスたちを考える。

src/hoge.py
class Hoge:
    ...
src/fuga.py
from hoge import Hoge

class Fuga(Hoge):
    ...
src/piyo.py
from fuga import Fuga

class Piyo(Fuga):
    ...

ディレクトリ構成は以下。

src
├── hoge.py
├── fuga.py
└── piyo.py

ここで、解きたいのはPiyoからファイルパスのハードコードなしでsrc/hoge.pysrc/fuga.pyを得るにはどうすればよいか?という問題。

TL;DR

こういうクラスメソッドを用意すればいい。

src/hoge.py
class Hoge:
    @classmethod
    def get_class_paths_in_my_project(cls):
        # specify the root directory of our project
        src_dir = Path.cwd() / "src"
        ret = []
        # lists classes with Method Resolution Order (MRO)
        for c in cls.mro():
            m = sys.modules[c.__module__]
            try:
                path = Path(m.__file__)
            except AttributeError: # this module is `builtins` module
                continue
            try:
                path.relative_to(src_dir)
            except ValueError: # not in src dir
                continue
            ret.append(path)
        return ret

解決策

まず、あるクラスの親クラスを列挙するには、以下のようにクラスメソッドmroを使えばいい(mroはMethod Resolution Orderのこと)。

src/hoge.py
class Hoge:
    ...

if __name__ == "__main__":
    print(Hoge.mro())

すると、結果として以下が得られる。

[<class '__main__.Hoge'>, <class 'object'>]

これに加えて、それぞれのモジュール(クラス)を取得するには次のようにする。
クラスのパス一覧を取得する関数をget_class_pathsと名づけて、python src/hoge.pyして実際に動作させてみる。

src/hoge.py
import sys

class Hoge:
    @classmethod
    def get_class_paths(cls):
        for c in cls.mro():
	    m = sys.modules[c.__module__]
	    print(m)

if __name__ == "__main__":
    print(Hoge.get_class_paths())

結果として以下が得られる。

<module '__main__' from '/path/to/src/hoge.py'>
<module 'builtins' (built-in)>

sys.modulesの説明は公式ドキュメントを参照。端的に言えば、実行中のPythonプロセスのモジュール名とモジュール自体が入った辞書。
これに対して、cls.mro()で列挙した親クラスのモジュール名をc.__module__で参照し、そこから実際のモジュールをsys.modulesより取得している。
得られるモジュールとしては、Hogeクラスが属す__main__(今回はpython src/hoge.pyを実行したので、hoge.py__main__になる)他に、objectクラスが属すモジュールであるbuiltinsがある。

モジュールからそのファイルパスを参照するには以下のようにする。

src/hoge.py
import sys

class Hoge:
    @classmethod
    def get_class_paths(cls):
        for c in cls.mro():
	    m = sys.modules[c.__module__]
	    path = m.__file__

__file__を参照するとそのモジュールが定義されているファイルの絶対パスがstr型で得られる。__file__については公式ドキュメントを参照。

ここで、Pythonの組み込み関数などが定義されているbuiltinsモジュールには、__file__が存在しないのでAttributeErrorが出る。
これはどうしようもなさそうなので、適当に以下のようにする。

src/hoge.py
import sys

class Hoge:
    @classmethod
    def get_class_paths(cls):
        for c in cls.mro():
	    m = sys.modules[c.__module__]
	    try:
		path = m.__file__
            except AttributeError: # this module is `builtins` module
                continue
	    print(path)

これで再度python src/hoge.pyを実行すると、結果として以下が得られる。

/path/to/src/hoge.py

次に、Hogeの継承先でどのように動作するかを確認する。
src/fuga.pyを以下のように書き換える。

src/fuga.py
from hoge import Hoge

class Fuga(Hoge):
    ...
    
if __name__ == "__main__":
    print(Fuga.get_class_paths())

python src/fuga.pyを実行すると次の結果が得られる。

/path/to/src/fuga.py
/path/to/src/hoge.py

Hogeとそれを継承したFugaのクラス定義が存在するファイルのパスが列挙されており、よさそう。

今回はHogeが特に何も継承していないので問題はないが、現状の実装では外部ライブラリを利用する際に問題が生じることがある。
具体的には、外部ライブラリのクラスを継承してHogeを定義する場合、その外部ライブラリのクラスのファイルパスまでこの結果に入ってきてしまう。

外部ライブラリのファイルを確認する必要がない場合、手元で自分が触れるコードのファイルパスのみを列挙できれば便利。
これは、自分のプロジェクトディレクトリをsrcとするなら、以下のようにpathlibrelative_toメソッドでsrcディレクトリ以下に存在するファイルのみを列挙することができる。

src/hoge.py
import sys
from pathlib import Path

class Hoge:
    @classmethod
    def get_class_paths_in_my_project(cls):
        src_dir = Path.cwd() / "src"
        for c in cls.mro():
	    m = sys.modules[c.__module__]
	    try:
		path = m.__file__
            except AttributeError: # this module is `builtins` module
                continue
            try:
                path.relative_to(src_dir)
            except ValueError: # not in src dir
                continue
	    print(path)

これは、pathlib.Pathrelative_toメソッドがsrcディレクトリからの相対パスを求める際に、srcより上のディレクトリにあるものとの相対パスを求めようとすると、例外を吐くという仕様を利用している。

上の機構を加えて、もろもろいい感じにした完成形が以下。

src/hoge.py
import sys
from pathlib import Path

class Hoge:
    @classmethod
    def get_class_paths_in_my_project(cls):
        # specify the root directory of our project
        src_dir = Path.cwd() / "src"
        ret = []
        # lists classes with Method Resolution Order (MRO)
        for c in cls.mro():
            m = sys.modules[c.__module__]
            try:
                path = Path(m.__file__)
            except AttributeError: # this module is `builtins` module
                continue
            try:
                path.relative_to(src_dir)
            except ValueError: # not in src dir
                continue
            ret.append(path)
        return ret

    @classmethod
    def print_paths(cls):
        print(cls.__name__)
        for path in cls.get_class_paths_in_my_project():
            print(path)


if __name__ == "__main__":
    Hoge.print_paths()
src/fuga.py
from hoge import Hoge

class Fuga(Hoge):
    ...

if __name__ == "__main__":
    Fuga.print_paths()
src/piyo.py
from fuga import Fuga

class Piyo(Fuga):
    ...

if __name__ == "__main__":
    Piyo.print_paths()

また、それぞれのファイルをpython src/...で実行すると、それぞれ以下の結果が得られる。

src/hoge.py
Hoge
/path/to/src/hoge.py
src/fuga.py
Fuga
/path/to/src/fuga.py
/path/to/src/hoge.py
src/piyo.py
Piyo
/path/to/src/piyo.py
/path/to/src/fuga.py
/path/to/src/hoge.py

無事、それぞれの継承先クラスから、欲しいファイルパスが得られていることが確認できた。

あとがき

cls.__name__で継承先のクラスの名前が取れるのも覚えておいてもいいかもしれない。
かなりニッチな気がするが、何かに役立てば幸いです。

Discussion