【Python】モジュールとパッケージを抽象的に理解してみる

# はじめに

Python のインポートシステムについて、

- モジュールは `.py` ファイル
- パッケージは `__init__.py` を置いたディレクトリ

などと理解してもよいが、
物理ファイルから離れ、少し抽象化して考えることによって、モジュール名、相対インポート、初期化タイミング、名前の可視性など、システム全体の見通しがよくなると思っている。

なお、本記事では **名前空間パッケージ[^1]** は解説の対象外としている (私が理解していないため)。

[^1]: https://docs.python.org/ja/3/reference/import.html#namespace-packages 

# 参考文献

いずれも v3.8.2 版を閲覧した。

- Python 言語リファレンス, 7.11. import 文: https://docs.python.org/ja/3/reference/simple_stmts.html#the-import-statement
- Python 言語リファレンス, 5. インポートシステム: https://docs.python.org/ja/3/reference/import.html
- Python チュートリアル, 6. モジュール: https://docs.python.org/ja/3/tutorial/modules.html

# 環境

Python v3.8.2 で動作確認した。pytest のテストケースとして動作を検証する。

```
pip install pytest
```

pytest 自体の動作確認については付録参照のこと。

# 概要

- モジュールとは名前空間の単位であり、インポートできるものである。
- すべてのコードは、あるモジュール (名前空間) の中で動いている。トップレベルモジュール名は `__main__` である。
- モジュールをインポートすると、モジュールとその名前空間を、現在の名前空間から参照することができる。
- パッケージとはサブモジュールを持つことのできるモジュールのことである。
- 一度インポートされたモジュールはグローバルにキャッシュされている。

属性の簡単なまとめ:

| | `__name__`|`__path__`|`__package__`|
|---|---|---|---|
|モジュール: |モジュール名|なし|親パッケージ名|
|パッケージ: |パッケージ名|パッケージのパス|パッケージ名|
 
# 名前空間 (namespace) とは

これはプログラミング言語で一般に使われる概念で、
**プログラムの相異なる場所で、変数名 `x` などの「名前」を、衝突を恐れずに違う意味で使えるための機能である。**
たとえば `builtins.open` と `os.open` はどちらも関数名だが、所属する名前空間が異なるために、
違うものとして定義・使用することができる。
次に説明する「モジュール」は、この **名前空間の単位** になっている。

なお、Python ドキュメントには用語集があって、「namespace」の項目がある: https://docs.python.org/ja/3/glossary.html#term-namespace

# モジュールとは・インポートとは

概要でも述べたように、モジュールとは **名前空間の単位であり、import できるもの** のことである。

## 名前空間の単位

まず、モジュールは名前空間の単位なので、モジュール `m` と `n` に対し、`m` 内の名前 `x` (= `m.x`) と `n` 内の名前 `x` (= `n.x`) は区別される。

## コードはすべて、ある名前空間で動作する

次に、あまり意識することがないが、すべてのコードはあるモジュールの中で、つまりある名前空間の中で動いている。
コードが名前を宣言、定義すると、その名前空間に登録される。
Python インタプリタが起動された直後は、**`__main__` モジュールの名前空間** でコードが動いている。
これがよくある、**ある `.py` ファイルが直接起動されたのかインポートされたのか区別する方法** に使われている。

## モジュールのインポート

次に、モジュールはインポートすることができる。モジュールをインポートするとは、

- モジュールを探してキャッシュし、
- ロードおよび初期化して、
- 現在の名前空間にモジュールを束縛する

ことである。ドキュメントには2ステップと書かれているが、3ステップだと思うと処理単位がわかりやすい。
簡単に言うと、モジュール `m` を `import m` としてインポートすることにより、

- モジュール `m` を `sys.path` から検索して、モジュールオブジェクトをグローバルキャッシュ `sys.modules` に登録する。
- モジュール `m` のロードおよび初期化により、その名前空間に名前を登録する。例えば `m.f` や `m.C` など。
- 現在の名前空間に 名前 `m` を登録して、これがモジュール `m` を指すようにする。

Python インタプリタを対話モードで起動して確かめてみる。(見やすさのため一部整形している)

```
[.../module-experiments]$ python
Python 3.8.2 (default, Apr 18 2020, 18:08:23) 
[Clang 10.0.1 (clang-1001.0.46.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.

>>> print(__name__)
__main__

>>> import m
>>> sys.modules['m']
<module 'm' from '.../module-experiments/m.py'>
>>> m
<module 'm' from '.../module-experiments/m.py'>
>>> m.f
<function f at 0x108452b80>
>>> m.C
<class 'm.C'>
```

開始直後のモジュールが `__main__` であることが確認できる。
また、`m` のインポートにより、`m`、`m.f` および `m.C` にアクセスできることがわかる。

なお、インポートされたモジュールはローカルな名前空間にキャッシュされる。
したがって、他の名前と同様に、関数内でインポートしたモジュールは他の関数からはアクセスできない。
ただしキャッシュはグローバルなのでアクセスできる。

# パッケージとは

パッケージとは単純に、その中でサブモジュールを検索、インポートできること以外はモジュールと全く同じである。
パッケージを使うことにより、モジュールの階層構造を作ることができる。

## 簡単に言うと

このとき、モジュール (またはパッケージ) の名前はドットで区切られた `a.b.c` のようなものになる。
モジュール `a.b.c` を `import a.b.c` でインポートすることにより、

- モジュール (パッケージ) `a` をインポートする
- モジュール `a` の名前空間において、サブモジュール (パッケージ) `b` (`a.b`) をインポートする
- モジュール `a.b` の名前空間において、サブモジュール `c` (`a.b.c`) をインポートする

ことにより、モジュール `a.b.c` に `a.b.c` でアクセスできるようになる。
**注意: `c` でアクセスできるようになるわけではない。**

また、このルールにより、パッケージでもモジュールでも同じようにインポートできることがわかる。

`a.b.c` をインポートした直後の状態においては、当然 `a.d` や `a.d.e` にはアクセスできない。
ここで `import a.d.e` を行うと、キャッシュに `a` が存在することから、

- モジュール `a` の名前空間において、サブモジュール (パッケージ) `d` (`a.d`) をインポートする
- モジュール `a.d` の名前空間において、サブモジュール `e` (`a.d.e`) をインポートする

だけが起こるので、これらの時点でモジュールの初期化が行われる。

## もう少し詳しく

テクニカルには、パッケージとは、`__path__` 属性を持つモジュールのことである。
この属性は、サブパッケージを探す際に用いられる。
つまり、`import a.b.c` においては、

- `a` を `sys.path` から探してインポートする
- モジュール `a` の名前空間において、`a.b` を `a.__path__` から探してインポートする
- モジュール `a.b` の名前空間において、`a.b.c` を `a.b.__path__` から探してインポートする

ということが起こっている。

なお、パッケージを含むモジュールは `__name__` 属性を持っている。
インポートされたモジュールについては、この値はモジュールの完全修飾名 (`a.b.c` みたいな) である。

## パッケージのインポート

パッケージはモジュールなので、モジュール同様に import 文でインポートできる。

# `import ... as ...`

import 文に `as` 節がある場合、インポートされたモジュール自身が `as` 以下の名前に束縛される。
例えば、 `import a.b as b` とすると、名前 `b` で直接モジュール `a.b` にアクセスできる。

このとき、モジュール `a` のロードと初期化は行われているため、モジュール `a` (と、当然 `a.b` も) に関する情報はキャッシュされている。しかし、名前空間に `a` は束縛されていない。

```python
>>> import sys
>>> import a.b as b
['/Users/shinsa/git/python-experiments/module-experiments/a']
a

>>> sys.modules['a']
<module 'a' from '/Users/shinsa/git/python-experiments/module-experiments/a/__init__.py'>
>>> sys.modules['a.b']
<module 'a.b' from '/Users/shinsa/git/python-experiments/module-experiments/a/b/__init__.py'>

>>> b
<module 'a.b' from '/Users/shinsa/git/python-experiments/module-experiments/a/b/__init__.py'>

>>> a
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'a' is not defined
```

## モジュール名と名前は異なる

`import` の直後に来るのはあくまでもモジュール名であり、この場所に、定義された名前を使うことはできない。
したがって、`import a.b as b` としたからといって、`import a.b.c` を `import b.c` と書くことはできない。

# `from` 節を持つインポート

この形式のインポートは `from a.b import c` のケースを取る。`c` はモジュールでもパッケージでも名前でもよい。
以下の処理が行われる。

- `a.b` をインポートする。ただし名前の束縛を行わない。
- `c` の束縛を試みる。
    - `c` が `a.b` で定義された名前である場合、`a.b.c` を `c` という名前に束縛する。
    - `c` がモジュールまたはパッケージである場合、`a.b.c` をインポートして (ただし名前は束縛しない)、`a.b.c` を `c` という名前に束縛する。

もし `as` 節が指定されている場合は、上記の例で `c` を束縛する代わりに、`as` 節で指定された名前に束縛する。

## `from ... import *`

インポートする名前が `*`、つまり `from M import *` の場合には、モジュール `M` の中で

- `__all__` が定義されている場合にはそれにより指定される名前
- そうでない場合には `M` で定義されている名前で `_` で始まらないもの

がすべて現在の名前空間で束縛される。

## 相対インポート

`from ... import ...` 文では、`from` 節に相対パッケージ表現を使用できる。
たとえば、パッケージ `a.b` の初期化スクリプト、もしくはモジュール `a.b.c` のスクリプトにおいて、

- `from .` は `from a.b` を、
- `from ..` は `from a` を、
- `from .d` は `from a.d` を、

それぞれ表す。ドットはもっとたくさんついてもよい。

## PEP 366

相対パッケージの計算は `__name__` 属性に基づいて行われていた。
すると、例えば `python -m a.b.c` でインタプリタを起動した時、`a/b/c.py` は `__main__` モジュールとして動作する。
これが原因となり、例えば `a.b.d` を相対インポートができないという問題があった。

新しいバージョンの Python では、`__package__` 属性に基づいて相対インポートが計算されるようになり、上記の呼び出し時の問題は解決した (PEP 366)。 ただし `python <path>/a/b/c.py` で起動した場合は、手動で属性をセットする必要がある。


# デフォルト実装: ファイルシステムに対するインポートシステム

さて、ここまで、モジュール、パッケージ、`__path__` については、抽象的に説明してきた。
つまり、具体的なファイルシステムでの話を全くしていない。
最近のバージョンでは、Python のインポートシステムは高度に抽象化されたパワフルなシステムになっている。

デフォルトでは、インポートシステムはファイルシステムを前提として動作する。
デフォルトではモジュールは `.py` ファイル、パッケージはディレクトリによって表現されている。
詳しくはこうなっており、これまでの抽象的なデータ構造にきっちりフィットすることがわかる。

- `sys.path` の内容はファイルパスであるとみなして検索を行う。`import m` により `sys.path` に含まれるディレクトリが順に検索されて、いずれかのディレクトリにある `m.py` がインポートされる。
- パッケージ `a.b` のロード、初期化は `a/b/__init__.py` の実行により行う。
- モジュール `a.b.c` のロード、初期化は `a/b/c.py` の実行により行う。
- パッケージ `a.b` の `__path__` 属性値は、`b` のディレクトリパスである。

逆に言うと、例えば URI ベースのインポートシステムを実装することができ、サービスディスカバリの仕組みを Python のインポートシステムを使って実現することができる。

<!--
# 付録

## pytest の動作確認

動作の確認には pytest を使用してみる。以下のような構成でモジュールとテストコードを置くことを想定している。
ここで、`test` ディレクトリに `__init__.py` を置いてパッケージとすることによって、
テストコードを実行するときにカレントディレクトリが `sys.path` に追加されるのでモジュールを import できる。
そうしない場合は、`PYTHONPATH` などにカレントディレクトリを追加してほしい。

```
.
├── a
│   ├── __init__.py
│   └── b
│       ├── __init__.py
│       └── c.py
├── main.py
└── test
    ├── __init__.py
    └── test_paths.py
```

それを `test_paths.py` で確認してみる。ついでに `rootdir` 変数がカレントであることも確認しておく。
テストコードは以下。

```python
import os
from os.path import samefile
import pprint
import sys


def test_cwd(pytestconfig):
    print(pytestconfig.rootdir)
    pprint.pprint(sys.path)
    cwd = os.getcwd()
    assert samefile(pytestconfig.rootdir, cwd)
    assert any(map(lambda f: samefile(cwd, f), sys.path))
```

テストしてみると、たしかに通る。なお、確認していないが、`os.getcwd()` の値は pytest を実行したディレクトリになる。

```
[.../module-experiments]$ pytest test
========================================= test session starts =========================================
platform darwin -- Python 3.8.2, pytest-5.4.1, py-1.8.1, pluggy-0.13.1
rootdir: .../module-experiments
collected 1 item                                                                                      

test/test_paths.py .                                                                            [100%]

========================================== 1 passed in 0.02s ==========================================
```
-->

# さいごに: ちょっと不満

Python のドキュメントは、いろいろなところに情報が散らばっていてわかりにくい。
情報を重複させないためにそうしているならまだ理解できるが、
そうではなくて、各情報が微妙に重複しているのに散らばっている。さらに一部の情報は古かったりする。