🍡

Pythonにおけるimportの仕組みとPytest

14 min read

PytestでPythonのテストを書いているときにテスト対象モジュールのimportやディレクトリ構成で色々詰まったので、これを機にPythonにおけるimportの仕組みから調べましたので記事にまとめます。

Pythonを書き始めてそれなりの年月が経ちましたが意外とそもそものimportの仕組みはなあなあで済ませていたので、個人的にはかなり勉強になりました。

この記事では大きく分けて以下2点についてまとめます。

  • Pythonのimportの仕組み
  • 上記を踏まえたテスト時(pytest)のimportについて

Pythonにおけるimportの仕組み

Pythonのプログラムを書いていれば当たり前のようにimportを使っていると思います。
importする対象としては主に以下3つに分けられると思いますが、そもそもどうしてこれらのライブラリをimportできるのでしょうか。

  • 自分で書いたプログラム
  • 標準ライブラリ(os, jsonなど)
  • サードパーティのライブラリ(requests, pandasなど)

本節では、このimportを成り立たせている仕組みについてまとめていきます。

基本的なPathの仕組み

sys.path

Pythonがインポートするモジュールを検索するパスのリストです。
プログラム内でimport xxxのようにimportを行った場合、このリストの先頭から順に各ディレクトリを探しに行きます。そのため、importで指定したライブラリがこのsys.pathで検索しに行くディレクトリの中に含まれていればimportできるというのが基本的な考え方になります。

細かな仕様については以下の通り公式ドキュメントにまとめられています。

起動時に初期化された後、リストの先頭 (path[0]) には Python インタプリタを起動したスクリプトのあるディレクトリが挿入されます。スクリプトのディレクトリがない (インタプリタが対話セッションで起動された時や、スクリプトを標準入力から読み込んだ場合など) 場合、 path[0] は空文字列となり、Python はカレントディレクトリからモジュールの検索を開始します。スクリプトディレクトリは、 PYTHONPATH で指定したディレクトリの 前 に挿入されますので注意が必要です。
sys --- システムパラメータと関数 — Python 3.10.0b2 ドキュメント

それでは実際にプロンプトやプログラムの実行時にどのようにsys.pathが設定されるかを見ていきます。

  • Mac
  • Python3.8.7(pyenv)

プロンプトからの実行時

プロンプトからsys.pathの内容を確認してみます。
以下の2パターンで結果を確認してみます。

  • 仮想環境を使った場合
  • 仮想環境を使わない場合

それぞれで結果は以下の通りになります。

仮想環境を使わない場合
>>> import sys
>>> import pprint
>>> pprint.pprint(sys.path)
['',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python38.zip',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/lib-dynload',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/site-packages']
仮想環境を使う場合
% python -m venv .venv
% source .venv/bin/activate

>> import sys
>>> import pprint
>>> pprint.pprint(sys.path)
['',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python38.zip',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/lib-dynload',
 '/Users/panyoriokome/Workspaces/git/python-testing/path/import_basics/.venv/lib/python3.8/site-packages']

スクリプトのディレクトリがない場合リストの先頭には空文字が挿入されるというのは公式ドキュメントに記載通りの挙動になります。
また、上記でポイントとなるのはpython3.8python3.8/site-packagesの2つのディレクトリです。

それぞれ

  • python3.8
    • 標準ライブラリがimportできる理由
  • python3.8/site-packages
      - サードパーティライブラリがimportできる理由

標準ライブラリがimportできる理由

繰り返しになりますが、python3.8がポイントです。

 '/usr/local/var/pyenv/versions/3.8.7/lib/python38.zip',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8', # ここがポイント
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/lib-dynload',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/site-packages']

このディレクトリ配下にはosjsonなどよく使うライブラリが格納されています。
ここに対するパスがデフォルトで通っているので、特にライブラリのインストール等を行わなくてもimport osのようなコマンドで標準ライブラリのimportが可能になっています。

$ pwd
/xxx/.pyenv/versions/3.8.7/lib/python3.8

$ tree -L 1
.
├── LICENSE.txt
...
├── json
├── keyword.py
├── lib-dynload
├── lib2to3
├── linecache.py
├── locale.py
├── logging
...

34 directories, 174 files

サードパーティライブラリがimportできる理由

サードパーティライブラリの場合にはpython3.8/site-packagesがポイントです。

 '/usr/local/var/pyenv/versions/3.8.7/lib/python38.zip',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8', 
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/lib-dynload',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/site-packages' # ここがポイント]

pipでライブラリをインストールすると

(.venv)% pip install requests

以下のようにsite-packages配下に対象のライブラリがインストールされ、

sys.pathでsite-packages配下を探しにいくよう設定がされているためimportができる。

(.venv) % python
>>> import requests # 正常にimportに成功する
>>> 

という流れです。

Pythonの実行時(自分で書いたプログラムがimportできる理由)

Pythonファイルを実行したときの挙動はどうなるのでしょうか。
sys.pathの内容を出力するだけの処理を作成し、実行結果を見てみます。

import_basics/path_print.py
import sys
import pprint

pprint.pprint(sys.path)

すると実行したPythonファイルのディレクトリがsys.pathに追加されることがわかります。
(公式ドキュメントの言葉を借りればPythonインタプリタを起動したスクリプトのあるディレクトリが挿入されます。になります。)

# 仮想環境を使わずに実行
% python path_print.py 
['/...python-testing/path/import_basics',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python38.zip',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/lib-dynload',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/site-packages']

# 仮想環境をアクティベートした状態で実行
(.venv)% pwd
/.../python-testing/path/import_basics
(.venv)% python path_print.py 
['/.../python-testing/path/import_basics',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python38.zip',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/lib-dynload',
 '/xxx/python-testing/path/import_basics/.venv/lib/python3.8/site-packages']

# ひとつディレクトリを上がってから実行
(.venv)% cd ..
(.venv)% python import_basics/path_print.py
['/.../python-testing/path/import_basics',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python38.zip',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/lib-dynload',
 '/.../python-testing/path/import_basics/.venv/lib/python3.8/site-packages']

このように、Pythonファイルを実行した際は対象ファイルが含まれるディレクトリがsys.pathに追加されるため、その配下に存在するモジュールのimportが可能になります。

実際に自作のモジュールを作成してimportが可能か試してみます。

以下のように実行対象のpath_print.pyと同一ディレクトリと子ディレクトリにモジュールを作成し、それをimportしてみます。

.
+ ├── add.py
+ ├── child_dir
+ │   └── substract.py
  └── path_print.py
import_basics/path_print.py
import sys
import pprint
from add import add
from child_dir.substract import substract

print('addの実行結果: ', add(1, 2))
print('substructの実行結果: ', substract(2, 1))
pprint.pprint(sys.path)

実行結果としては以下の通りで、正常にimportができていることが確認できました。

% python path_print.py
addの実行結果:  3
substructの実行結果:  1
...

このようにPythonファイルの実行時には公式ドキュメントに記載のある通り実行対象のPythonファイルが存在するディレクトリがsys.pathに追加されるため配下の自作プログラムがimport可能になることが確認できました。

pytestにおけるimport

Pytestにおける基本的な方針(ベストプラクティス)

PytestのベストプラクティスについてはGood Integration Practices — pytest documentation にまとめられています。
なお、ここではアプリケーションコード(テスト対象)とテストコードを分けるディレクトリ構成を前提にします。

上記のページに以下のディレクトリ構成がサンプルとしてまとめられています。

setup.py
mypkg/
    __init__.py
    app.py
    view.py
tests/
    test_app.py
    test_view.py
    ...

そして、テストの実施に関して以下2つの方法が挙げられています。

  • パスを通す方法としてはsetup.pyを用意してpipでインストールする
  • 上記をしたくない場合にはpython -m pytestでカレントディレクトリをsys.pathに追加して実行
  • Your tests can run against an installed version after executing pip install ..
  • Your tests can run against the local copy with an editable install after executing pip install --editable ..
  • If you don’t have a setup.py file and are relying on the fact that Python by default puts the current directory in sys.path to import your package, you can execute python -m pytest to execute the tests against the local copy directly, without using pip.
    Good Integration Practices — pytest documentation

まず、前提として、アプリケーションコード(テスト対象)とテストコードを分けるディレクトリ構成の場合、デフォルトの状態ではパスが通っていないのでテストコード側でアプリケーションコードのimportができません。(この後具体例付きでまとめていますので、詳細はそちらをご確認ください)
そのため、何らかの方法でパスを通す必要がありますが、それが上記の2つの方法になります。

2つの方法でimportができるようになる理由について簡単にまとめると以下の通りになります。

  • pip install -e
  • python -m pytest
    • python -m pytestの形式で呼び出すとカレントディレクトリをsys.pathに追加してくれる
    • 上記の例だとmypkg/をカレントディレクトリ配下にすることでimportが可能になる

カレントディレクトリのsys.pathへの追加

2つの方法のうち、python -m pytestの形式で呼び出すとカレントディレクトリをsys.pathに追加してくれる件について、挙動を確認してみます。

# カレントディレクトリの確認
(.venv) % pwd
/.../python-testing/path/import_basics

# pytestコマンドで実行した場合
% pytest pytest -s          
...
pytest/test_path.py ['/.../python-testing/path/import_basics/pytest', # 実行したファイルのパスが追加される
 '/.../python-testing/path/import_basics/.venv/bin',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python38.zip',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/lib-dynload',
 '/.../python-testing/path/import_basics/.venv/lib/python3.8/site-packages']


# python -m をつけて実行した場合
(.venv) % python -m pytest pytest -s
...                                                                              
pytest/test_path.py ['/.../python-testing/path/import_basics/pytest',
 '/.../python-testing/path/import_basics', # カレントディレクトリがパスに追加される
 '/usr/local/var/pyenv/versions/3.8.7/lib/python38.zip',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8',
 '/usr/local/var/pyenv/versions/3.8.7/lib/python3.8/lib-dynload',
 '/.../python-testing/path/import_basics/.venv/lib/python3.8/site-packages']

上記の通りpython -m pytestの形式で呼び出すとカレントディレクトリがパスに追加されることが確認できました。

実例

ここまででPythonのimportの仕組みとPytestにおけるテスト実行のベストプラクティスをまとめましたので、実際にプロジェクトを作って試してみます。

プロジェクトの構成

ディレクトリ構成

以下のような構成でアプリケーションコードとテストコードが分かれてます。

.
├── src
│   ├── add.py
│   ├── multiply.py
│   └── substruct.py
└── tests
    └── unit
        ├── test_add.py
        ├── test_multiply.py
        └── test_substruct.py

テストファイル

テスト自体は以下のようなものを用意します。

test_add.py
from src.add import add

def test_add():
    expect = 5

    result = add(3, 2)

    assert expect == result

テストの実行

カレントディレクトリのsys.pathへの追加

# pytestコマンドで実行するとエラーになる
$ pytest tests
tests/unit/test_add.py:1: in <module>
    from src import add
E   ModuleNotFoundError: No module named 'src'

# python -m で呼び出すと実行できる
$ python -m pytest tests
collected 3 items                                                                

tests/unit/test_add.py .                                                   [ 33%]
tests/unit/test_multiply.py .                                              [ 66%]
tests/unit/test_substruct.py .                                             [100%]

=============================== 3 passed in 0.02s ================================

相対importにすると

test_add.py
from ...src.add import add

相対importはできませんというエラーが出ます。

    from ...src.add import add
E   ImportError: attempted relative import with no known parent package

ローカルパッケージのインストール

新たにsetup.pyを追加して、pip installでパッケージとしてインストールします。

.
+├── setup.py # 追加
├── src
│   ├── add.py
│   ├── multiply.py
│   └── substruct.py
└── tests
    └── unit
        ├── test_add.py
        ├── test_multiply.py
        └── test_substruct.py

setup.pyの内容はGood Integration Practices — pytest documentation に記載されているものそのまま。

setup.py
from setuptools import setup, find_packages

setup(name="PACKAGENAME", packages=find_packages())

pipでインストールすると以下の通りpytestコマンドで実行した時もパスが通っているのでテストが実行できます。

$ pip install -e .
...
Installing collected packages: PACKAGENAME
  Running setup.py develop for PACKAGENAME
Successfully installed PACKAGENAME

# この状態ならpytestコマンドでもOK
$ pytest tests
...
collected 3 items                                                                

tests/unit/test_add.py .                                                   [ 33%]
tests/unit/test_multiply.py .                                              [ 66%]
tests/unit/test_substruct.py .                                             [100%]

=============================== 3 passed in 0.02s ================================

sys.pathに追加する他の方法

import関係では他にも色々なパスの通し方があり、調べた中では今回取り上げたもの以外で主に以下の方法がありそうでした。

  • importlibの利用
  • PYTHONPATHの利用
  • パス設定ファイル(.pth)の追加

ただ、現時点ではこの記事にまとめた基本的なやり方で事足りているため、特に取り上げていません。
今後テストコードを書く中で必要性が出てきたら適宜追加していきたいと思います。

参考資料

Discussion

ログインするとコメントできます