【python】「*.pth」配置で別ディレクトリからimportする方法

先日、以下の記事でpythonをインストールしてから開発を進め、今回はunittestモジュールを用いてユニットテストをする段階になりました。

anyenvで特定のディレクトリに指定したバージョンのpythonをインストールしました。簡単にディレクトリ毎にバージョンを切り替える事が可能で、他の言語でも同じフローで応用できて環境構築手順がシンプルになるメリットがあります。

後述するディレクトリ構成でファイルを配置しましたが、理解が足りず、別ディレクトリにあるテストコードから自作モジュールのimportがうまく出来ないでいました。

そこで「*.pth」ファイルを配置することでpythonのモジュール検索パスに任意のディレクトリを追加できることを知って、それを利用すると別ディレクトリからimportが可能になりました。

結論を先に言うと、追加したいモジュール検索パスを/path/to/moduleとした場合に、以下のコマンドを打つとパスが通りました。

$ echo /path/to/module > $(python -c 'import sys; print(sys.path)' | grep -o "[^']*site-packages")/module.pth

私の環境は以下の通りです。

環境 バージョン
MacOS 10.13.2
python 3.6.4

以下で具体例を交えて、詳しく書いていきます。

今回構築したディレクトリ・ファイル構成

以下で示すのはサンプルコードですが、ソースコードとそれに対応するテストコードを1対1で対応させるために、ソースコードがあるmainディレクトリと、テストコードがあるtestsディレクトリは同じ構成にしました。

/opt
└── app
    ├── main
    │   ├── business
    │   │   └── calc.py
    │   └── main.py
    └── tests
        └── business
            └── test_calc.py
ファイル名 役割
main.py このアプリケーションで最初に実行されるコード(この記事上では実行に使用してません)
calc.py main.pyによって使用されるCalcクラスのコード
test_calc.py calc.pyをテストするコード

以下はそれぞれのコードの内容です。

from business.calc import Calc
## Calcクラスのaddメソッドを利用する
calc = Calc()
print(calc.add(2 + 3))
class Calc:
  ## 引数を足し算して返すだけのaddメソッド
  def add(self, a, b):
    return a + b

test_calc.pycalc.pyをimportする時に、main.pyと同じ記述(from business.calc import Calc)でimportした方が直感的にわかりやすいと思い、この形式で記載したテストコードを実行しました。

import unittest
from business.calc import Calc   ## main.pyと同じ記述でimportしたい

class TestCalc(unittest.TestCase):
  def setUp(self):
    self.__calc = Calc()

  ## Calcクラスのaddメソッドをテストする
  def test_add_ok(self):
    self.assertEqual(self.__calc.add(2, 3), 5)   ## 2 + 3 = 5 (正)

  def test_add_fail(self):
    self.assertEqual(self.__calc.add(2, 3), 6)   ## 2 + 3 = 6 (誤)

当初、/opt/app/testディレクトリを作ろうとしましたが、python標準で同名のtestモジュールが既に存在するので、python公式ドキュメントを参考に、それと混在して不具合が起きないように末尾にsをつけてtestsと命名しました。

$ python -c 'import test; print(test)'
<module 'test' from '/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/test/__init__.py'>
$

別ディレクトリから自作モジュールのimportに失敗した

しかしこのままテストコードtest_calc.pyを実行すると、「businessモジュール」が見つからず、ModuleNotFoundErrorという実行時エラーになりました。

$ python -m unittest /opt/app/tests/business/test_calc.py
E
======================================================================
ERROR: test_calc (unittest.loader._FailedTest)
----------------------------------------------------------------------
ImportError: Failed to import test module: test_calc
Traceback (most recent call last):
  File "/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/unittest/loader.py", line 153, in loadTestsFromName
    module = __import__(module_name)
  File "/opt/app/tests/business/test_calc.py", line 2, in <module>
    from business.calc import Calc   # main.pyと同じ記述でimportしたい
ModuleNotFoundError: No module named 'business'


----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (errors=1)
$ 

ModuleNotFoundErrorが出ている原因

Python公式ドキュメントによると、モジュールをimportする時に、以下の順番でモジュールが検索されます。

  1. ビルトインモジュールにあるか探す
  2. sys.pathに格納されているディレクトリにあるか探す。

デフォルトの状態だと、自作のbusinessモジュールはどちらにも該当していません。よって/opt/app/tests配下にあるtest_calc.pyから、/opt/app/main配下にある「businessモジュール(calc.pyがあるディレクトリ)」が見えないため、ModuleNotFoundErrorが出ていました。

モジュール検索パスを追加する

以下では自作モジュールへのパスを、前述した2のsys.pathにモジュール検索パスとして追加することで、先ほどのコードやディレクトリ構成は変えずに、モジュールの名前解決ができるようにします。

「*.pth」ファイルを使ってパスを通す方法

以下のコマンドでsys.pathにデフォルトで格納されたモジュール検索パスを確認できます。(見やすいようにsedコマンドで改行表示しています。)

$ python -c 'import sys; print(sys.path)' | sed s/,/,\\$'\n'/g
['',
 '/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python36.zip',
 '/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6',
 '/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/lib-dynload',
 '/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/site-packages']
$ 

python公式ドキュメントによると、上記で表示された中の*/site-packagesのディレクトリに*.pthというファイルを置くと、その中に書かれたパスがpython実行時にモジュール検索パスとして追加されます。その結果、そのパスに配置されたモジュールをどのファイルからでもimportできるようになります。

「*.pth」ファイルを配置する

以下のようにすると、例えばapp.pth*/site-packagesに配置して、その中に書かれたパス(今回は/opt/app/main)をsys.pathにモジュール検索パスとして追加することができます。

## app.pthを作成して、モジュール検索パスを追加
$ echo /opt/app/main > $(python -c 'import sys; print(sys.path)' | grep -o "[^']*/site-packages")/app.pth

## 作成したファイルの中身を確認
$ ls -l $(python -c 'import sys; print(sys.path)' | grep -o "[^']*/site-packages")/app.pth
-rw-r--r--  1 stedplay  _sophos  14  1 22 20:45 /Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/site-packages/app.pth
$ cat $(python -c 'import sys; print(sys.path)' | grep -o "[^']*/site-packages")/app.pth
/opt/app/main
$

## モジュール検索パスが通っていることを確認(見やすいようにsedで改行表示しています)
$ python -c 'import sys; print(sys.path)' | sed s/,/,\\$'\n'/g
['',
 '/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python36.zip',
 '/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6',
 '/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/lib-dynload',
 '/Users/stedplay/.anyenv/envs/pyenv/versions/3.6.4/lib/python3.6/site-packages',
 '/opt/app/main']
$

/opt/app/mainにモジュール検索パスを通すことができました。
これで、このディレクトリ配下はどのpyファイルからもimportでアクセスできるようになりました。

別ディレクトリから自作モジュールのimportに成功した

再びテストコードtest_calc.pyを実行すると、実行時エラーは発生せず成功しました。

そして2つのテスト(test_add_oktest_add_fail)の内、テストコードの足し算の結果が誤っていた後者だけがテストに失敗していることを確認できました。

$ python -m unittest /opt/app/tests/business/test_calc.py
F.
======================================================================
FAIL: test_add_fail (opt.app.tests.business.test_calc.TestCalc)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/opt/app/tests/business/test_calc.py", line 13, in test_add_fail
    self.assertEqual(self.__calc.add(2, 3), 6)   ## 2 + 3 = 6 (誤)
AssertionError: 5 != 6

----------------------------------------------------------------------
Ran 2 tests in 0.001s

FAILED (failures=1)
$

Dockerに応用すると

ちなみに私はDockerでpythonの環境構築をしているので、以下のRUNコマンドをDockerfileに記載して、イメージ作成時にモジュール検索パスを追加するようにしています。

FROM python:3.6.4-alpine3.7

## add module search path
RUN echo /opt/app/main > $(python -c 'import sys; print(sys.path)' | grep -o "[^']*/site-packages")/app.pth
...以下略...

まとめ

「*.pth」ファイルを使ってpythonのモジュール検索パスを追加して、任意のディレクトリから自作モジュールをimportする方法をまとめました。

今回のように手間をかけてモジュール検索パスを通さずに、テストコードを自作モジュールが直接見える位置に置くことでも対応可能です。

しかし、時間が経って後で見返したときに、ソースコードとそのテストコードが1対1対応している直感的なディレクトリ構成の方が、その仕組みを私は理解しやすいと思い、あえて今回の構成にしました。