【Python】エントリポイントの違いによるimport問題の解決

アプリケーション本体のエントリポイントから実行する場合は実行上何の問題がなくとも、
各モジュールファイルをエントリポイントとして起動した場合にはimport文で躓くということがあったので、その解決法を調査しました。

- 6/12: try～except文のexceptがcatchになっていたため修正しました。

# importはエントリポイントとしたディレクトリが起点

サンプルとして、下記のようなディレクトリ・ファイル構成を考えます。

```
main.py ... アプリケーションとしての実行ファイル
|
|--[src] ... モジュールを格納するディレクトリ
    |
    |-- module1.py ... main.pyから呼び出すモジュール
    |-- module2.py ... module1.pyから呼び出すモジュール
```

アプリケーションとしてのエントリポイントがmain.pyの場合、pythonのパスとして認識されるのはmain.pyのあるディレクトリのみです。
そのため、このアプリケーションを構成する全てのファイルは、main.pyのあるディレクトリを起点としたimportが必要になります。
つまり、上記のディレクトリ構造でmodule1.pyからmodule2.pyを使用したい場合は、

```python:/src/module1.py
# -*- coding: utf-8 -*-

import module2 # ImportError!
```

ではダメで、

```python:/src/module1.py
# -*- coding: utf-8 -*-

import src.module2 # OK
```

としなければなりません。

# srcディレクトリ内部のファイルから起動できなくなる問題

運用時はこの記述でよいのですが、module1.py内にテストコードを書くような場合に問題が発生します。

ここでいうテストコードというのは、module1.py内のクラスや関数などの動作を確認するものを想定しています。
書いたコードの動作をその場で確認するために、そのファイル内にある関数を呼び出す簡単なコードを書いてF5キーを押すのは割とよくある光景なのではないでしょうか？

しかし、`import src.module2`という記述をしてしまうと、module1.pyをエントリポイントとしてプログラムを起動した場合に`src`の部分が認識できずImportErrorが発生します。
これは、module1.pyを起点とした場合の実行ディレクトリがsrcディレクトリに変わってしまうためです。
srcディレクトリ内には、`src`というディレクトリは存在しないのです。

本記事ではこの問題を解決し、main.pyからでもmodule1.pyからでもプログラムを起動できるようなアイデアを3つ紹介します。
ちなみに、どれもデメリットがありこれをやればOKというものではないです。
もっとスマートな方法があれば募集しておりますのでコメントください。

- モジュールのファイル内でsys.path.append()する方法
- \_\_init\_\_.pyでsys.path.append()する方法
- ImportErrorをcatchして分岐する方法

# モジュールのファイル内でsys.path.append()する方法

module1.py内に`sys.path.append()`を記述することで解決する方法です。
割と一般的な方法かなと思います。

```python:/src/module1.py
# -*- coding: utf-8 -*-

import sys, os
sys.path.append(os.path.join(os.path.dirname(__file__), '.'))
import module2
```

これは、pythonのパスにmodule1.pyのファイルのあるディレクトリを追加するというものです。
これを実行することで、pythonのパスにsrcディレクトリが追加されることになり、import文に`src`がなくてもエラーが発生しなくなります。
つまり、module2.pyのimportは、`import module2`のみでよいことになります。

しかしこの方法では、モジュールファイルが大量にあった場合にはファイルごとへの上記の記述が必要になってしまう問題があります。
また、`sys.path.append()`はmodule1.pyを使用している全ファイルに反映されるため、名前空間が汚染されます。
(main.py内でも`import module2`ができるようになってしまいます。)

# \_\_init\_\_.pyでsys.path.append()する方法

srcディレクトリの直下に\_\_init\_\_.pyを配置し、その中で`sys.path.append()`を行う方法です。
この場合、ディレクトリ構成は次のようになります。

```
main.py ... アプリケーションとしての実行ファイル
|
|--[src] ... モジュールを格納するディレクトリ
    |
    |-- __init__.py ... sys.path.append()を実行するファイル
    |-- module1.py ... main.pyから呼び出すモジュール
    |-- module2.py ... module1.pyから呼び出すモジュール
```

また、\_\_init\_\_.pyの中身は次のようになります。

```python:/src/__init__.py
# -*- coding: utf-8 -*-

import sys, os
sys.path.append(os.path.join(os.path.dirname(__file__), '.'))
```

これは何をしているのかというと、main.pyで`import src.module1`をする際には\_\_init\_\_.pyも暗黙的にimportされることを利用しています。
具体的には、main.pyから`import src.module1`を実行する時、まず\_\_init\_\_.pyが先にimportされ、次にmodule1.pyがimportされるように動作します。
そのため、

1. \_\_init\_\_.pyのimport時に前述の`sys.path.append()`が実行され、srcディレクトリがpythonのパスに追加される。
2. module1.pyがimportされるが、すでにsrcディレクトリがパスに追加されているため、`import module2`の指定でmodule2.pyをimportできる。

という動作になります。
この方法であれば、srcディレクトリ内にある各モジュールには`sys.path.append()`が不要なため、コード量を削減することができます。

ただし、`sys.path.append()`を使用する以上、名前空間汚染の問題は残ります。

# ImportErrorをcatchして分岐する方法

`sys.path.append()`は使用せず、ImportErrorをcatchしてimport先を動的に変化させる方法です。
具体的には、下記のようなコードになります。

```python:/src/module1.py
# -*- coding: utf-8 -*-

try:
    import src.module2 # main.pyから起動した場合はこちらが実行される
except ImportError:
    import module2 # module1.pyから起動した場合はこちらが実行される
```

`import src.module2`に失敗したら、代わりに`import module2`を実行するという動作になっています。
通常、アプリケーションはmain.pyから実行されるため、実行ディレクトリからはsrcディレクトリが見えています。
そのため、main.pyからの起動時にはtryブロックの中身は問題なく実行されます。

一方、module1.pyをエントリポイントとして起動した場合はsrcディレクトリが見えないため、ImportErrorが発生します。
そこで、exceptブロックでImportErrorをcatchし、import先の記述を変えることでうまくimportができるようにします。

この手法は`sys.path.append()`を使用しないため名前空間の汚染が発生しません。

しかし、各モジュールファイルに上記の記述を追加しなければならないという問題は残ります。

# まとめ

本記事では、main.pyからでもmodule1.pyからでもプログラムを起動できるようになる方法を3つ説明しました。

- モジュールのファイル内でsys.path.append()する方法
- \_\_init\_\_.pyでsys.path.append()する方法
- ImportErrorをcatchして分岐する方法

ただし、どの方法にもデメリットが存在するため環境に合わせて使い分ける必要があるのかなあと思っています。