Pythonの相対パスimportを理解する

# はじめに
Pythonで自作モジュールをimportする際に以下のようなエラーに引っかかることがあります。
- `ValueError: attempted relative import beyond top-level package`
- `ImportError: attempted relative import with no known parent package`
- `ModuleNotFoundError`

これらを解決するために色々調査をし、自分の中で腹落ちしたので備忘も込めて書き残します。
個人的には以下の理解が深まりました。

:::note info
キーワード
- モジュール検索パス(sys.path)
- 相対パスimport
:::


# 環境
- Python 3.8.10


# 前提知識
## import文のおさらい
importとはモジュールを取り込むための構文です。では、モジュールとは何か？

> モジュールは Python の定義や文が入ったファイルです。ファイル名はモジュール名に接尾語 .py がついたものになります。([python公式ドキュメント](https://docs.python.org/ja/3.8/tutorial/modules.html#the-module-search-path)から引用)

非常にシンプルな話で、`.py`拡張子のファイル自体をモジュールと呼ぶってことですね。で、モジュール名はファイル名そのものだと。例えば、ファイル名が`mod.py`ならモジュール名は`mod`になる、ということです。

以下のような構成で、`python main.py`を実行すると、`this is func of mod.py`が出力されます。

```
~/  ←カレントディレクトリ
 main.py
 mod.py
```

```python:mod.py
def func():
  print("this is func of mod.py")
```

```python:main.py
import mod
mod.func()
```

## from句のおさらい
importは、インポート対象のファイルの中身全てを取り込みます。対してfromは、一部の関数のみ取り込みたいといった場合に使用します。
以下、mod.pyのfunc_Aのみをインポートする例です。func_Bはインポートされてないので、実行できません。

```
~/  ←カレントディレクトリ
 main.py
 mod.py
```

```python:mod.py
def func_A():
  print("this is func_A of mod.py")

def func_B():
  print("this is func_B of mod.py")
```

```python:main.py
from mod import func_A
func_A() # 正常に実行される
func_B() # 実行時エラー
```

## パッケージのおさらい
> パッケージ (package) は、Python のモジュール名前空間を "ドット付きモジュール名" を使って構造化する手段です。
([python公式ドキュメント](https://docs.python.org/ja/3.8/tutorial/modules.html#the-module-search-path)から引用)

例えば以下のようなディレクトリ構造だとします。この場合、sub_module_A.pyは`package.sub_pack_A`というパッケージに属します。
```
~/  ←カレントディレクトリ
 main.py
 package/
           module_A.py
           module_B.py
           sub_pack_A/
                      sub_module_A.py
           sub_pack_B/
                      sub_module_B.py
```

# エラーを解消するのに必要な知識
ここまでが前提知識になります。ここからは、冒頭述べた、importに関する種々のエラーを解消するにあたって必要になってくる知識を述べます。

## モジュール検索パス(sys.path)について
> spam という名前のモジュールをインポートするとき、インタープリターはまずその名前のビルトインモジュールを探します。見つからなかった場合は、 spam.py という名前のファイルを sys.path にあるディレクトリのリストから探します。
([python公式ドキュメント](https://docs.python.org/ja/3.8/tutorial/modules.html#the-module-search-path)から引用)

sys.pathの中身を確認してみます。
```python:main.py
import sys
print(sys.path)
```
main.pyを実行してみると、以下のようなリストが出力されます。
`[<pythonの実行パス>, '/usr/lib/python38.zip', '/usr/lib/python3.8', '/usr/lib/python3.8/lib-dynload', '/home/s-kento/work_for_python/module_test/.venv/lib/python3.8/site-packages']`

importが実行されると、sys.pathに格納されているパス配下を探索するということになります。

ちなみに、以下のような状況で、sub_module_A.pyを直接pythonコマンドで実行すると、`pythonの実行パス`=`~/package/sub_pack_A`になります。

```
~/  ←カレントディレクトリ
 main.py
 package/
           module_A.py
           module_B.py
           sub_pack_A/
                      sub_module_A.py
           sub_pack_B/
                      sub_module_B.py
```

## 相対パスimportについて
何度も登場させていますが、以下のような状況です。

```
~/  ←カレントディレクトリ
 main.py
 package/
           module_A.py
           module_B.py
           sub_pack_A/
                      sub_module_A.py
           sub_pack_B/
                      sub_module_B.py
```

sub_module_A.pyからsub_module_B.pyをimportしようとすると、以下のように書けます。
```python:sub_module_A.py
from package.sub_pack_B import sub_module_B
```

ここで、**sub_module_A.pyのパスを基準にして**相対パスを使うと、以下のようにも書けます。
```python:sub_module_A.py
from ..sub_pack_B import sub_module_B
```

Linuxでよく`cd ..`するのと同じように、`..`は一つ上位の階層を表します。

# 【本題】自作モジュールのimportで発生するエラーを解消する
ここからは、自分がハマッてしまったエラーに対して、その解決方法を書いていきます。

## ValueError: attempted relative import beyond top-level package
以下のような状況で、module_A.pyでmodule_B.pyをインポートしたいとします。
```
~/  ←カレントディレクトリ
 main.py
 package_A/
           module_A.py
 package_B/
           module_B.py
```
package_Bはmodule_A.pyの上位ディレクトリだから・・・と思って以下のように書き、main.pyを実行すると、module_A.pyのimport文で`ValueError: attempted relative import beyond top-level package`が発生します。
```python:module_A.py
from ..package_B import module_B
```
```python:main.py
from package_A import module_A
```

エラーメッセージの通りなのですが、相対パスimportではトップレベルのパッケージを超えた上位ディレクトリは参照できないということです。どういうことか？
前節で述べたパターンは、こんな感じで`sub_package_A`配下から`package`配下に遡った形ですが、
```
package.sub_package_A.sub_module_A
       ↑<============↑
```
エラーが発生するパターンは、こんな感じでトップディレクトリである`package_A`を跨ごうとしてるんですね。
pythonの仕様的にこれはやってはいけねえぞ、ということです。
```
 package_A.module_A
↑<=======↑
```

### 解決策
そのまんまですが、`..`はいらないということです。
```python:module_A.py
from package_B import module_B
```

## ImportError: attempted relative import with no known parent package
以下のような構成で、sub_module_A.pyからsub_module_B.pyをimportしようとします。今度はmain.pyではなくsub_module_A.pyを直接実行すると`ImportError: attempted relative import with no known parent package`が発生します。
```
~/  ←カレントディレクトリ
 main.py
 package/
           module_A.py
           module_B.py
           sub_pack_A/
                      sub_module_A.py
           sub_pack_B/
                      sub_module_B.py
```
```python:sub_module_A.py
from ..sub_pack_B import sub_module_B
```
main.pyを実行した時と何が違うかというと、sys.pathの中身が違います。sub_module_A.pyを実行するとsys.pathには`~/package/sub_pack_A`が入ります。
なので、sub_module_A.pyはpythonを実行しているルートディレクトリ上にいることになり、pythonの仕様上それより上位は遡れねぇぞ、ということです。

### ModuleNotFoundError
上の状況に対して、`..`を取り除き以下のように書くと、今度は`ModuleNotFoundError`が発生します。相対パスimportに対するエラーではなくなり、単純にsub_pack_Bが見つからねぇぞ、というエラーです。sys.pathには`~/package/sub_pack_A`が入っており、`sub_package_A`配下に`sub_package_B`はいないので、見つからないのは当然ですね。
```python:module_A.py
from sub_package_B import module_B
```

### 解決策
2通りあります。
#### -mオプションを使用する
pythonコマンドを実行する際、`-m`オプションを使用します。このオプションは、モジュール名を指定することによりスクリプトを実行するオプションです。
上の状況において、`python package/sub_pack_A/sub_module_A.py`と`python -m package.sub_pack_A.sub_module_A`とで何が違うかと言うと、pythonの実行ディレクトリが異なります。
前者は`~/package/sub_pack_A/`なのに対し、後者は`~/`になります。その結果、後者は`package.sub_pack_A.`と`package.sub_pack_B`がパッケージとして認識されるようになり、相対パスimportも可能になります。

#### sys.pathにパスを加える
sys.pathに上位ディレクトリを追加します。具体的にはこんな感じです。
```python:sub_module_A.py
import sys
sys.path.append("~/package")
from sub_pack_B import sub_module_B
```

これで、import実行時に`/package`ディレクトリ配下も探索してくれるので、`sub_pack_B`が見つかるようになります。

# おわりに
モジュール単体を実行する時ってどんな時かと言うと、自分の場合は単体テストをしている時でした。
`pytest`で単体テストを作成中、importエラーで小一時間詰まったので、本記事を書くに至りました。

調査がちょっと雑なので、もしかしたら間違ってる箇所があるかもしれませんが、お気づきの際はご指摘もらえると大変ありがたいです。
(自分で見返してみても日本語が分かりにくい箇所がちょいちょいあるので、随時修正していこうと思ってます。。)

あと、解決策2種類しか書いてませんが、たぶんカレントディレクトリ変える方法とかもあるのではと思ってます。
ただそこは調査できてないので、余力があれば調べて追記したいと思います。

以上です！ご精読ありがとうございました！