JP / EN

広告
2023/03/03

Python サブディレクトリ内のスクリプトからのインポート

タグ:python

結論

パッケージのルートディレクトリからサブディレクトリ内のスクリプトsubdir/script.pyを実行するときは python -m subdir.script とするか、スクリプトの先頭に import.sys; sys.path.insert(0, "./") と書いておいてから python subdir/script.py とする。

解説

Pythonプロジェクトで、直接実行する用のスクリプトをサブディレクトリに配置したいことがある。 例えば雑多なツール類をscripts/などにまとめて置きたいときだ。 このような場合スクリプトの実行のためにいちいちcdするのは面倒だから、 パッケージのルートから直接サブディレクトリ内のスクリプトを実行できるようにしたい。
    $ tree
    .
    ├── main.py
    └── scripts
        └── tool.py
    $ python scripts/tools.py
    hello
  


単純なスクリプトであれば問題ないが、tool.pyの中で親パッケージ内の別のモジュールをインポートしたいとき、 ややこしいことが起こる。
実験用に次のようなパッケージを考える。
    $ tree
    .
    ├── a.py
    ├── subdir1
    │   └── b.py
    └── subdir2
        └── c.py

    $ cat subdir1/b.py
    def hello():
        print("I am pacage b")

    $ cat subdir2/c.py
    import subdir1.b as b
    b.hello()
  
このようにスクリプトsubdir2/c.pyからパッケージ内の共通ライブラリ的なモジュール subdir1/b.py内の関数を呼び出したい、という想定だ。
ところがこれはパッケージルートからは直接実行できない。
    $ python subdir2/c.py
    Traceback (most recent call last):
      File "subdir2/c.py", line 9, in 
        import subdir1.b as b
    ModuleNotFoundError: No module named 'subdir1'
  
subdir1が見つからないとエラーが出てしまう。
これはなぜかというと、Pythonではスクリプト実行時のインポートパスにはコマンド実行元の カレントディレクトリではなくスクリプトの配置場所が入ってしまうためである。 インポートパスとはsys.pathで確認できる、Pythonがimport文でパッケージを検索する対象 のパスのリストである。 これをスクリプトを少し改造して確認しよう。
    $ cat subdir1/b.py
    import os
    print("cwd:", os.getcwd())  # カレントディレクトリをプリント

    import sys
    import pprint
    print("path:")
    pprint.pprint(sys.path)  # インポートパスをプリント

    import subdir1.b as b
    b.hello()

    $ python subdir2/c.py
    cwd: /mnt/c/Users/user/tmp/
    path:
    ['/mnt/c/Users/user/tmp/subdir2',  # /mnt/c/Users/user/tmp/ ではない!
    '/usr/lib/python38.zip',
    '/usr/lib/python3.8',
    '/usr/lib/python3.8/lib-dynload',
    '/usr/local/lib/python3.8/dist-packages',
    '/usr/lib/python3/dist-packages']
    Traceback (most recent call last):
      File "subdir2/c.py", line 9, in 
        import subdir1.b as b
    ModuleNotFoundError: No module named 'subdir1'
  
のように確かにカレントディレクトリ/mnt/c/Users/user/tmpではなく スクリプトの置き場所/mnt/c/Users/user/tmp/subdir2がインポートパスに 指定されてしまっている。これではsubdir1にアクセスできないではないか。
ちなみに相対インポートで親ディレクトリからのインポートを試みてもだめである。
    $ cat subdir2/c.py
    from .. import subdir1.b as b
    b.hello()

    $ python subdir2/c.py
    Traceback (most recent call last):
    File "subdir2/c.py", line 12, in 
      from . import subdir1
    ImportError: attempted relative import with no known parent package
  
これはPythonの仕様上、実行中のpyファイルはパッケージと見なされないことに由来する。 パッケージと見なされなければ親パッケージも定義されず、相対インポートは失敗するのだ。 このときに"ImportError: attempted relative import with no known parent package"という ややわかりにくいエラーメッセージとなる。

解決策としては、1. -mオプションによるモジュール実行を行う、2. sys.pathに./を追加してsubdir1 をインポートできるようにする、の2通りがある。

解決策1. -mオプションによるモジュール実行を行う例:
    $ cat subdir2/c.py  # 元に戻した
    import subdir1.b as b
    b.hello()

    $ python -m subdir2.c
    I am pacage b
  
Pythonの実行時に-m [モジュール名]を渡すと[モジュール名]を実行するが、 このときはインポートパスが実行元のディレクトリになっている。 そのためsubdir1を問題なく参照できる。

解決策2. sys.pathに./を追加する例:
    $ cat subdir2/c.py  # 変更を加えた
    import sys
    sys.path.insert(0, "./")
    import subdir1.b as b
    b.hello()

    $ python subdir2/c.py
    I am pacage b
  
sys.pathは手で編集すればそれ以降のimportで追加されたパスも見に行ってくれる。
1. と大して変わらないが、しいて言うとモジュール名subdir2.cにシェルのtab補完 が効かないのでファイル名などが長いときはこちらの方が楽か。


このエントリーをはてなブックマークに追加

https://wonderhorn.net/