最近遇到 import python 自定義模塊出現報錯的問題,搗鼓了很久,終于算是比較搞清楚了,為了描述清楚,有測試項目目錄如下:
環境描述:
- 自定義模塊 pkg_a,包含兩個文件 file_a_01.py,file_a_02.py(以下測試模塊的測試文件都分別有兩個包含模塊標識的文件,不一一贅述了)
- 自定義模塊 pkg_c,屬于 pkg_a 模塊的子模塊
- 自定義模塊 pkg_b
- 入口文件目錄 src,模擬常規的運行腳本 runner.py 以及一個僅包含一些字符串的配置文件 conf.py
- 頂級目錄入口文件 test.py
- 以上每個文件運行都會打印出自己的文件名,方便根據輸出判斷導入成功與否
- 本項目使用了 python 3.7 的虛擬環境
經常出現的使用場景:
場景一:頂級目錄入口文件導入
在 test.py 文件測試了以下代碼:
# 導入配置文件 src/conf.py
import src.conf
# 導入包 pkg_a
import pkg_a.file_a_02
輸出:
I'm conf.py
I'm pkg_a_02
分析:
- src 里沒有包含 init.py 為什么還可以導入成功?
原因是 python3.3 之后支持了隱式命名空間包,可以參考https://www.python.org/dev/peps/pep-0420/#specification - 從頂級目錄可以導入包,這里為什么不需要寫
sys.path.append(${項目目錄})
?
test.py 處于項目的根目錄,在運行此入口文件的時候,會自動把入口文件所在的文件夾目錄添加到sys.path
里面,有興趣的同學可以在運行入口文件的時候輸出sys.path
看一下
場景二:次級目錄入口文件導入
在 runner.py 文件測試了以下代碼:
# 導入配置文件 conf.py
import conf
# 導入包 pkg_a
import pkg_a.file_a_02
輸出:
I'm conf.py
Traceback (most recent call last):
File "src/runner.py", line 9, in <module>
import pkg_a.file_a_02
ModuleNotFoundError: No module named 'pkg_a'
分析:
- 導入 pkg_a 為什么會報錯?
import sys
for p in sys.path:
print(p)
"""
輸出:
test-python-import/src
...
"""
通過 python xxx.py 運行的時候,會把 xxx.py 所在的目錄添加到 sys.path 的 0 位,可以通過輸出 sys.path 觀察到
從上面的輸出可以看出,運行 runner.py 的時候,僅把 runner.py 對應的目錄添加到了 sys.path 里面,所以 python 去找 package 的時候出現了找不到的問題。這個時候可以通過 sys.path.append('${project_pth}/test-python-import/src')
來臨時解決問題,但是這里又引入了另外一些很繁瑣的問題,如果有 runner_01.py,runner_02.py,runner_0n.py ... 那么每次又要再寫一遍
-
pylint 出現的錯誤提示產生的誤導
如果這個目錄是個命名空間目錄,而不是包目錄,那么 pylint 會出現如下圖的報錯,這個看起來也算正常,畢竟此時 sys.path 的確沒有 pkg_a 的信息
pylint 報錯提示
那么,這個時候模擬另外一種情況,往 src 里添加一個 init.py 文件,讓這個目錄變成包,如下圖所示:
詭異的事情出現了,現在的 pylint 提示變成了找不到 conf,但是卻找得到 pkg_a
再次運行一下 runner.py ,此時輸出,結果還是不變的找不到 pkg_a:
I'm conf.py
Traceback (most recent call last):
File "src/runner.py", line 2, in <module>
import pkg_a.file_a_02
ModuleNotFoundError: No module named 'pkg_a'
然后出現了一個會詭異的情況,我換了一個行,不報錯了...我也是很醉
有興趣的同學可以繼續研究一下這個報錯是啥問題,有一些方向:
- pylint 對 namespace pkg 的支持還不是很好
https://github.com/PyCQA/pylint/issues/2862
https://github.com/PyCQA/pylint/issues/842 - pylint init-hook
在 .vscode/settings.json 里添加:
"python.linting.pylintArgs": [
"--init-hook",
"import sys; sys.path.insert(0, './')"
]
總之,不要被 pylint 誤導了,上面有情況出現 pylint 沒有對 pkg_a 檢查出導入報錯,但是還是運行的時候找不到包,所以最好還是通過運行時判斷,不要相信 pylint。后續談到的 pth 解決方案也會解決這個 pylint 的問題(但梅開二度說一次還是請不要相信 pylint,時刻記得 import 只和 python 的搜索路徑有關,手動狗頭)
場景三:模塊導入另外一個模塊
在 pkg_a/file_a_01.py 測試了以下代碼
from pkg_b import file_b_01
import file_a_02
print('I am pkg_a_01')
輸出:
I'm pkg_a_02
Traceback (most recent call last):
File "pkg_a/file_a_01.py", line 2, in <module>
from pkg_b import file_b_01
ModuleNotFoundError: No module named 'pkg_b'
分析:
- 導入報錯
同樣的問題,此時 python 在路徑里找不到 pkg_b - 相對導入問題
修改一下 pkg_a/file_a_01.py 的代碼如下:
from . import file_a_02
from pkg_b import file_b_01
print('I am pkg_a_01')
輸出:
Traceback (most recent call last):
File "pkg_a/file_a_01.py", line 1, in <module>
from . import file_a_02
ImportError: cannot import name 'file_a_02' from '__main__' (pkg_a/file_a_0
1.py)
這個問題出現的 __main__ 是不是很眼熟,就是我們經常會寫一個代碼
if __name__ == '__main__':
pass
這個 __name__ 其實就是命名空間,當 file_a_01.py 自運行的時候,__name__ 會被設置為 __main__,所以這個相對導入會出現錯誤。但是如果這樣寫,被別的文件引用的時候就不會報錯(前提是能找到 pkg_a),試試在頂級目錄入口文件 test.py 導入 pkg_a/file_a_01.py
import src.conf
import pkg_a.file_a_01
輸出
I'm conf.py
I'm pkg_a_02
I am pkg_b_01
I am pkg_a_01
這里也牽涉了一個平常不太注意的問題,當我們寫一個包的時候,應該怎么導入該包內其他的文件呢?有興趣的同學可以參考一下著名的包 requests 的導入寫法,里面用的全部都是相對導入。當然也有另外一種寫法,就是 import pkg_name.xxx,類比到上面的例子就是:
# 相對導入
from . import file_a_02
# 帶包名的寫法
from pkg_a import file_a_02
通過這些例子可以加強對導入的理解,本質上還是理解路徑搜索
場景四:次級模塊導入另外一個模塊
在 pkg_c/file_c_01.py 測試了以下代碼
import pkg_b
print('I am pkg_c_01')
輸出
Traceback (most recent call last):
File "pkg_a/pkg_c/file_c_01.py", line 1, in <module>
import pkg_b
ModuleNotFoundError: No module named 'pkg_b'
分析
- 導入報錯,都讀到這的同學,估計都已經非常清楚導入報錯問題了,子模塊也是同理沒什么特殊的地方
這里討論一個常見的使用場景,次級模塊導入上級模塊的文件,把 pkg_c/file_c_01.py 代碼修改為:
from .. import file_a_02
print('I am pkg_c_01')
輸出:
Traceback (most recent call last):
File "pkg_a/pkg_c/file_c_01.py", line 1, in <module>
from .. import file_a_02
ValueError: attempted relative import beyond top-level package
再結合兩個例子一起說明問題
- 在 test.py 進行調用
import src.conf
import pkg_a.pkg_c.file_c_01
輸出
I'm conf.py
I'm pkg_a_02
I'm pkg_c_01
- 修改一下文件內容
pkg_a/pkg_c/file_c_01.py
print(__name__)
from ...pkg_b import file_b_01
print("I'm pkg_c_01")
test.py 保持例子 1 不變,仍然導入 file_c_01.py
import src.conf
import pkg_a.pkg_c.file_c_01
輸出:
I'm conf.py
pkg_a.pkg_c.file_c_01
Traceback (most recent call last):
File "test.py", line 7, in <module>
import pkg_a.pkg_c.file_c_01
File "/xxx/xxx/test-python-import/pkg_a/pkg_c/file
_c_01.py", line 2, in <module>
from ...pkg_b import file_b_01
ValueError: attempted relative import beyond top-level package
分析:
為什么會出現 attempted relative import beyond top-level package
這個報錯?
通過 .. 或者 ... 這種相對查找父級目錄的導入,是相對該文件的目錄(命名空間)去找的
參考例子 2 的情況:
- file_c_01 的 __name__ 輸出是 pkg_a.pkg_c.file_c_01
- 此時如果通過 ... 這個去找的時候會超出 pkg_a 的范圍,因為 .. 就已經是頂級包名 pkg_a 了,所以會報錯
參考一開始那個自運行的例子:
- __name__ 是 __main__,所以再通過 .. 去找父目錄的時候,就會報錯
有個博主的博文說的也比較清楚,可以參考:https://blog.csdn.net/SKY453589103/article/details/78863050
解決方案
上述通過幾個實際的例子說明了一些常見的報錯原因,下面來討論一下解決辦法。
先來看一下目前現網給出的比較多的解決辦法,具體怎么做就不展開了,一搜一大堆:
- 最原始的,環境變量添加項目根目錄進去
- 這種方法比方法 2 好的就是不用寫那么多冗余代碼,但是可移植性太差了,不推薦
- 每次使用之前需要添加包的絕對路徑進去
sys.path.append('${absolute_path}')
- 這種方法應該是比較多人使用的了,但是每次都要寫這堆代碼到文件頭上,太冗余了
- 很容易被 IDE 格式化,比較不友好
- 會欺騙 pylint 的檢查,梅開三度再吐槽一下 pylint
本文推薦的解決方案是虛擬環境 + pth 文件解決導入問題
首先來說一下使用虛擬環境的好處(可能還有些同學沒接觸,就啰嗦一下)
- 將項目環境與系統環境隔離,這樣安裝包的時候不會與系統環境的包發生沖突。尤其是在公用的機器上,系統環境的沖突往往引發一堆沒必要的問題
- python cli ,如果系統存在 python 和 python3 的時候,每次運行腳本都要 python3 xxx.py,很不利于拼寫的補全(因為每次都是先出來 python 要自己補充一個 3)。如果更改 python 指向了 python3,又可能引發很多系統的問題,因為系統很多地方還是用的 python2。
具體使用參考官網,這里就不展開了
https://docs.python.org/3/tutorial/venv.html
以 python 3.7 為例子,創建好虛擬環境之后,在 venv/lib/python3.7/site-packages 下添加一個文件 myproject.pth (名字隨便都可以),然后添加項目的絕對路徑:
${絕對路徑}/test-python-import
添加完之后,無論在項目的哪個目錄運行腳本,python 都能搜到我們的根目錄,這樣我們導入包的時候,就可以直接通過包名導入了。例如,在 src/runner.py 這種二級目錄不做額外處理是找不到根目錄下 pkg_a 這種包名,在添加完路徑之后,就可以搜得到。
具體參考官網:https://docs.python.org/3/library/site.html
也可以看一下 stackoverflow 的討論:https://stackoverflow.com/questions/10738919/how-do-i-add-a-path-to-pythonpath-in-virtualenv
所以理解好 import 本質還是要理解好 python 的路徑搜索問題