【Python】なぜその相対インポートはエラーになるのか？原理を解説する【ImportError】

Pythonをそれなりにやっている人なら薄々気づいていると思いますが、Pythonのインポートシステムってクソ分かりにくいですよね。私もPythonで複数ファイルのプログラム書いてて`ImportError`を一度も出さずに済んだ事あったかな？ってレベルでした。あるスクリプトは動いてたのにサブディレクトリに入れたら動かなくなったり、同じ相対インポートが動くときと動かないときがあったり、すごくややこしい。

なぜそんなに面倒なことになるのか一応納得できる説明を得たので、それを解説します。

# つまずきポイント

まずは私が今まで経験したインポート関係のエラーの例を挙げます。後にこれらがなぜ起きたのかを説明できるようになります。

## 事例1：親ディレクトリの`.py`をインポート
```
/
+ project1/ (カレントディレクトリ)
  + package1/
    - hoge.py
    + tools/
      - tool1.py
  - main.py
```

```python:project1/package1/tools/tool1.py
from .. import hoge
```

このとき`python3 package1/tools/tool1.py`を実行してもインポートエラーになる。

しかし以下のようにして、`python3 main.py`を実行し、`main.py`経由で`tool1.py`を呼び出すとエラーにならない。

```python:project1/main.py
from package1.tools import tool1
```

→ 同じコードなのにエントリポイントがどこかによって挙動が変わるの？

## 事例２：相対インポート
```
/
+ project2/ (カレントディレクトリ)
  + hoge/
    - tool.py
    - hoge_util.py
  - main.py
  - util.py
  - call_main.py
  - call_tool.py
```

```python:project2/main.py
from . import util
```

```python:project2/hoge/tool.py
from . import hoge_util
```

このとき`python3 main.py`や`python3 hoge/tool.py`はいずれもインポートエラーになる。しかし以下のようにして、`python3 call_tool.py`を実行し、`call_tool.py`経由で`tool.py`を呼び出せばエラーにならない。

```python:project2/call_tool.py
from hoge import tool
```

一方で以下のようにして、`python3 call_main.py`を実行し、`call_main.py`経由で`main.py`を呼び出すのは引き続きエラーになる。

```python:project2/call_main.py
import main
```

→ 相対インポート(`.` から始まるインポート)が使えたり使えなかったりするのは何なんだよ！

## 事例３：標準ライブラリが壊れる
```
/
+ project3/ (カレントディレクトリ)
  - main.py
  - re.py
```

`re.py`という名前のファイルを置いておくと、動作がおかしくなることがある。例えば `python3` コマンドでインタラクティブシェルを開くと以下のエラーが表示される。

```console
$ python3
Python 3.10.12 (main, Mar 22 2024, 16:50:05) [GCC 11.4.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
This is re.py
Failed calling sys.__interactivehook__
Traceback (most recent call last):
  File "/usr/lib/python3.10/site.py", line 466, in register_readline
    import rlcompleter
  File "/usr/lib/python3.10/rlcompleter.py", line 34, in <module>
    import inspect
  File "/usr/lib/python3.10/inspect.py", line 43, in <module>
    import linecache
  File "/usr/lib/python3.10/linecache.py", line 11, in <module>
    import tokenize
  File "/usr/lib/python3.10/tokenize.py", line 38, in <module>
    cookie_re = re.compile(r'^[ \t\f]*#.*?coding[:=][ \t]*([-\w.]+)', re.ASCII)
AttributeError: module 're' has no attribute 'compile'
Error in sys.excepthook:
Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/apport_python_hook.py", line 62, in apport_excepthook
    if not enabled():
  File "/usr/lib/python3/dist-packages/apport_python_hook.py", line 28, in enabled
    return re.search(r'^\s*enabled\s*=\s*0\s*$', conf, re.M) is None
AttributeError: module 're' has no attribute 'search'

Original exception was:
Traceback (most recent call last):
  File "/usr/lib/python3.10/site.py", line 466, in register_readline
    import rlcompleter
  File "/usr/lib/python3.10/rlcompleter.py", line 34, in <module>
    import inspect
  File "/usr/lib/python3.10/inspect.py", line 43, in <module>
    import linecache
  File "/usr/lib/python3.10/linecache.py", line 11, in <module>
    import tokenize
  File "/usr/lib/python3.10/tokenize.py", line 38, in <module>
    cookie_re = re.compile(r'^[ \t\f]*#.*?coding[:=][ \t]*([-\w.]+)', re.ASCII)
AttributeError: module 're' has no attribute 'compile'
>>>
```

# なぜこんなことになるのか？
なぜこのような不思議なことが起こるのか、Pythonのインポートシステムの解説を2つの要素に分けて行います。

## 全てはモジュール！
まず、Pythonではすべての`.py`ファイルをモジュールとして扱います。スクリプトファイル的な概念はなく、すべては標準ライブラリの`datetime`や`os`と同じモジュールなのです。普段 **`python main.py`として実行しているのも`main`モジュールを実行している** わけです。そして **`main`モジュールは`datetime`や`os`と等価な一モジュールにすぎない** のです。

なぜ標準ライブラリの`datetime`モジュールとカレントディレクトリの`main.py`が等価に扱われるかというと、Pythonでは **`python main.py`を実行するときに`.py`ファイルのあるディレクトリをモジュールの検索パスに追加する** からです。Pythonのモジュールは`sys.path`で表される検索パスから検索されます。この検索パスには`/usr/lib/python3.10/`のような標準ライブラリが入るディレクトリや`pip`でインストールしたモジュールが入るディレクトリと一緒に、指定した`.py`ファイルを含むディレクトリが追加されるのです。このことによって開発中のプロジェクトディレクトリ内の他の`.py`ファイルがインポートできるわけです。

追加されるのはカレントディレクトリではなく、`.py`ファイルがあるディレクトリである事にも注意が必要です。例えば`/project1/`がカレントディレクトリの状態で`python package1/tools/tool1.py`を実行したときに検索パスに追加されるのは`/project1/package1/tools/`であり、カレントディレクトリとは異なります。

## パッケージは再帰的な階層構造*じゃない*！
またもう一つ重要なこととして、Pythonのインポートシステムでは **検索パスの直下のディレクトリや `.py` ファイルはそれ以下の孫、ひ孫、...と根本的に違う意味を持ちます**。検索パスの直下のディレクトリやファイルは互いに独立な「パッケージ」を表しており、その下の孫以下のファイルやディレクトリは全てそのパッケージの内容物となります。直下なのかそれ以下なのかで、再帰的な階層構造では片づけられない異なる意味論を持ち、**動作に影響します**。ファイルシステムのディレクトリの概念は再帰的ですが、Pythonのパッケージの概念は再帰的ではないと言えます。

例えば以下のディレクトリ構造で `python /project1/main.py` と実行した時、`/project1/` がモジュール検索パスに追加されます。この時ここに存在するパッケージは `foo, hoge, main, util` です。`bar` はパッケージではなくサブパッケージと呼ばれ、パッケージである `foo` や `hoge` とは根本的に異なるものです。`foobar.py` が属するパッケージも `bar` ではなく `foo` であるということになります。また、`fuga.py` と `piyo.py` 及び `main.py` と `util.py` は一見同じ関係性に見えますが、`fuga.py` と `piyo.py` は同じ `hoge` パッケージに属する一方、`main.py` と `util.py` はそれ単体で別々のパッケージを構成します。

```:python3 /project1/main.pyと実行した時
/
+ project1/
  + foo/              ← パッケージ
    + bar/            ← サブパッケージ（パッケージとは異なる）
      - foobar.py
  + hoge/             ← パッケージ
    - fuga.py
    - piyo.py
  - main.py           ← パッケージ
  - util.py           ← パッケージ
```

この概念が現れるのが相対インポートです。**相対インポートでは自パッケージ外のモジュールは参照できません** 。相対インポートでインポートできるのは同じパッケージ内のモジュールだけであり、異なるパッケージ内のモジュールを読み込むには必ず絶対インポートが必要です。一方でサブパッケージは同じパッケージの中であれば跨いで相対インポートすることができます。つまり相対インポートでは、検索パスの直接の子ディレクトリ間は跨げないが、同じ直接の子の中でさえあればどんな場所にあろうと読み込めるということです。直接の子はそれ以下の孫、ひ孫とは異なる特別な扱いがされていると言えます。

すでに示している通り、パッケージはその名前のイメージと異なり、単一の `.py` ファイルのみから構成されることもあります。上の例では `main.py` と `util.py` がそれにあたり、これらは一つのファイルで一つのパッケージを構成します。よってこれらの `.py` ファイルでは相対インポートは一切使えないことになります。なぜならパッケージ内のモジュールがその1ファイルだけなので、相対インポートを使って読み込む他のパッケージ内モジュールが無いためです。

# つまずきポイントを振り返る
以上により、つまずきポイントの挙動の理由が理解できたはずです。

## 事例1
`python3 /project1/package1/tools/tool1.py` では `/project1/package1/tools/` が検索パスに追加されるので、その親ディレクトリ内である `/project1/package1/hoge.py` は検索パス外となり見えません。`python3 /project1/main.py` なら検索パスは `/project1/` になるのでそこから `tool1.py` を呼んで `hoge.py` も呼び出せます。

## 事例2
まず、`python3 /project2/hoge/tool.py` を実行したときは以下のような構成になります。

```
/
+ project2/
  + hoge/              ← 検索パス
    - tool.py          ← パッケージ
    - hoge_util.py     ← パッケージ
  - main.py
  - util.py
  - call_main.py
  - call_tool.py
```

よって `tool.py` と `hoge_util.py` は別パッケージとなるため互いを相対インポートすることはできません。

次に `python3 /project2/main.py` または `python3 /project2/call_main.py` または `python3 /project2/call_tool.py` を実行した場合は以下のようになります。

```
/
+ project2/            ← 検索パス
  + hoge/              ← パッケージ
    - tool.py
    - hoge_util.py
  - main.py            ← パッケージ
  - util.py            ← パッケージ
  - call_main.py       ← パッケージ
  - call_tool.py       ← パッケージ
```

この場合、上記の通り全部で5つのパッケージがあります。`tool.py` と `hoge_util.py` は同じ `hoge` パッケージに属しますが、それ以外は全て別のパッケージです。よって `tool.py` から `hoge_util.py` は相対インポートできますが、`main.py` から `util.py` はパッケージが別なのでどうやっても相対インポートできません。また `call_tool.py` から `hoge/tool.py` をインポートするときは絶対インポートを使っていたので意図通り動いていました。

## 事例3
標準ライブラリもプロジェクトのディレクトリの `.py` ファイルも同じ検索パスの仕組みでインポートされるので、標準ライブラリのパッケージと同じ名前の `.py` ファイルを置くと混同して動かなくなることがあります。既述したように、実行したファイルの隣のファイルをインポートできるのは実行したファイルのあるディレクトリが、標準ライブラリも入る検索パスに追加されてるだけです。

今回の場合は `re` というパッケージが[正規表現を扱う標準ライブラリ](https://docs.python.org/ja/3/library/re.html)と同じ名前になっているため混同して動かなくなりました。

ただし同じ名前だと必ず壊れるわけではない気がするのでぶっちゃけ正確な仕組みは私もよく分かってません。例えば同じく標準ライブラリの `io` と同じ名前の `io.py` を置いて `import io` をしてもなぜかそちらは読み込まれませんでした。

# インポートシステム難しい
とまあ、わかってしまえば理解はできる仕様なわけですが、でもやっぱり難解だと思います。JavaScriptやGo、はたまたC言語のようにURLやパスを直接指定するような単純明快な仕組みにできなかったのか？って気がします。

PythonのこのインポートシステムはPythonをカジュアルなスクリプト的に書き始めて、規模が大きくなればサブディレクトリにまとめながらコンポーネント化していくという流れと相性が悪いと思います。ディレクトリの直接の子か孫以下かで挙動が変わるとかね。

検索パスに実行するファイルのディレクトリを追加するというやり方は、隣に置いた`.py`ファイルが読み込めないのは困る！という問題を解決するための場当たり的な方策にしかなってない気がします。JavaScriptやGoのようにURLをそのまま指定する形式にして、`./`または`../`から始まるパスは自身のモジュールのからの相対パス、それ以外は標準ライブラリやインストールされたライブラリの入った場所から読み込む、みたいにした方が良かったのでは。