[Python] 環境変数PYTHONPATHを使用する際にハマりそうな注意点

ハマる前に気づいたのでセーフ。ただPYTHONPATHってそんな使わないかも。
2023.03.24

pythonを動かす時、PYTHONPATHという環境変数が参照されます。 これはsys.pathに影響を与える大事な環境変数なのですが、 ちょっと使い方を間違えると意図しないパスが追加されてしまう可能性があるということに気づいたので、 備忘録ですがご紹介します。

検証環境

  • Python3.10

(一応Python2.7でも動かしてみましたが、同じ結果でした)

基本の動き

環境変数PYTHONPATHに値が入っていると、それがsys.pathに反映されます。 例えばこんな感じです。 (結果は見やすいように整形しています)

$ pwd
/Users/hirano.shigetoshi
$ PYTHONPATH="/aaa/bbb" python ccc/ddd.py
[
  '/Users/hirano.shigetoshi/ccc',
  '/aaa/bbb',
  '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python310.zip',
  '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10',
  '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10/lib-dynload',
  '/opt/homebrew/lib/python3.10/site-packages'
]

2つ目に指定したPYTHONPATHが入っていることがわかります。 先頭に入っているのはスクリプトディレクトリで、 今回の場合起動スクリプトはccc/ddd.pyを指定しているので、 そのディレクトリであるcccが入っています。

PYTHONPATHは(PATHなどと同じように):で区切って複数指定することも可能です。

PYTHONPATH指定時の注意

基本の動きがわかったところで、今回気づいた注意点です。

PYTHONPATHが未定義or空文字列の場合

環境変数PYTHONPATHが未定義だったり、定義されていても空文字列が入っている場合、 sys.pathには何も変化を及ぼしません。

$ PYTHONPATH="" python ccc/ddd.py
[
  '/Users/hirano.shigetoshi/ccc',
  '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python310.zip',
  '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10',
  '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10/lib-dynload',
  '/opt/homebrew/lib/python3.10/site-packages'
]

空文字列なんだから当たり前じゃね?

そう、当たり前なんですが、これはsys.path「空文字列はカレントディレクトリを表す」という性質と突き合わせると違和感があります。

「空文字列はカレントディレクトリを表す」について一応見てみましょう。

sys.pathの空文字列がカレントディレクトリを指していることの確認

$ ls -l ccc
total 4
-rw-r--r-- 1 hirano.shigetoshi staff  0  3 24 12:08 __init__.py
-rw-r--r-- 1 hirano.shigetoshi staff 27  3 24 11:45 ddd.py
$ python
Python 3.10.6 (main, Aug 11 2022, 13:36:31) [Clang 13.1.6 (clang-1316.0.21.2.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys.path
['', '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python310.zip', '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10', '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10/lib-dynload', '/opt/homebrew/lib/python3.10/site-packages']
>>> sys.path = sys.path[1:]
>>> sys.path
['/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python310.zip', '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10', '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10/lib-dynload', '/opt/homebrew/lib/python3.10/site-packages']
>>> import ccc
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ModuleNotFoundError: No module named 'ccc'
>>> sys.path.insert(0, "")
>>> sys.path
['', '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python310.zip', '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10', '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10/lib-dynload', '/opt/homebrew/lib/python3.10/site-packages']
>>> import ccc
>>> ccc.__file__
'/Users/hirano.shigetoshi/ccc/__init__.py'

sys.path = sys.path[1:]''sys.pathから消した状態で import cccすると「そんなものはない!」と言われます。 importはsys.pathからファイルを探す仕組みですので当たり前です。 次にsys.path.insert(0, "")sys.path''を追加してimport cccするとパッケージがimportできました。 確かに''がカレントディレクトリを指していることがわかります。

空文字列は立派な値ですし、 もしかしたらユーザがカレントディレクトリを追加したい意思を持って空文字列にしているという可能性もゼロじゃない(本当?)ので、 無視されてしまうのはちょっと乱暴は気もしますが、 さすがに意図せず空文字列になっている可能性の方が圧倒的に高いと思いますので、 この挙動の方が弊害は少ないという判断なのかなと思います。

PYTHONPATH:で区切って複数書ける

さて、今度は複数のPYTHONPATH:で区切った場合の話です。 環境変数PATHと同じように:で結合できますので、PATHの慣例に従ってこんなふうに書けます。

PYTHONPATH="/aaa/bbb:$PYTHONPATH"

PYTHONPATHが未定義の状態でこれを実行するとどうなるでしょうか?

$ unset PYTHONPATH
$ PYTHONPATH="/aaa/bbb:$PYTHONPATH" python ccc/ddd.py
[
  '/Users/hirano.shigetoshi/ccc',
  '/aaa/bbb',
  '/Users/hirano.shigetoshi',
  '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python310.zip',
  '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10',
  '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10/lib-dynload',
  '/opt/homebrew/lib/python3.10/site-packages'
]

3つ目にご注目。カレントディレクトリが追加されました! 実際環境変数として渡された文字列は

/aaa/bbb:

です。 未定義のPYTHONPATHは空文字列として展開されます。 ここまでの話の流れだと、この:の後ろの空文字列はsys.pathに影響を及ぼさないで欲しいと思うのですが、 実際は、この空文字列はカレントディレクトリを指していると解釈されてしまいます。

PYTHONPATH="/aaa/bbb:$PYTHONPATH" という書き方は、 「すでに設定されているものを採用しつつ左側に追加する」という意図で書きました。 しかし残念ながらこの書き方はPYTHONPATHでは意図通りではない動きになっています。 元のPYTHONPATHが定義済みの空文字列ならまだしも、 未定義であった時でもカレントディレクトリとして追加されてしまうのでかなり怖いです。

回避方法

とはいえ、 「すでに設定されているものを採用しつつ追加をする」 という発想での書き方は普通に行いたいですので、 その場合は、以下のように書く必要があります。

$ unset PYTHONPATH
$ PYTHONPATH="/aaa/bbb${PYTHONPATH:+:}$PYTHONPATH" python ccc/ddd.py
[
  '/Users/hirano.shigetoshi/ccc',
  '/aaa/bbb',
  '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python310.zip',
  '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10',
  '/opt/homebrew/Cellar/python@3.10/3.10.6_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10/lib-dynload',
  '/opt/homebrew/lib/python3.10/site-packages'
]

${PYTHONPATH:+:}はめっちゃ読みづらいですが、 :+は「左辺の変数が未定義や空文字列でない時は右辺を出力する」です。 これでPYTHONPATHが未定義や空文字列の時は:も出力しないという書き方が実現できます。

ただこれを常用するかっていうと、ちょっと読みづらいしつらいところですね。 スクリプトの中で厳密にやりたいときに使用するかもってくらいが現実かなと思います。

まとめ

3.5行まとめ。

  • PYTHONPATHが未定義or空文字列のときはsys.pathに影響を及ぼさない
  • PYTHONPATHが未定義でも空文字列でもない時はsys.pathに何らかの影響を及ぼす
  • :で区切った結果に空文字列があれば、それはカレントディレクトリとしてsys.pathに登録する
    • PATHと同じ気分で繋げる時は特に注意

特に触っていてハマったとかではなく、調べ物をしている中でたまたま気づいたのですが、 正直この事情を全部気にしながら環境作りをするのはかなり大変なので、 PYTHONPATHは使いたくないなぁというのが感想です。

以上、どなたかの理解に繋がれば幸いです。

参考