Python import 報錯問題

最近遇到 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'

然后出現了一個會詭異的情況,我換了一個行,不報錯了...我也是很醉


詭異換行之后正常

有興趣的同學可以繼續研究一下這個報錯是啥問題,有一些方向:

"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'

分析:

  1. 導入報錯
    同樣的問題,此時 python 在路徑里找不到 pkg_b
  2. 相對導入問題

修改一下 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'

分析

  1. 導入報錯,都讀到這的同學,估計都已經非常清楚導入報錯問題了,子模塊也是同理沒什么特殊的地方

這里討論一個常見的使用場景,次級模塊導入上級模塊的文件,把 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

再結合兩個例子一起說明問題

  1. 在 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
  1. 修改一下文件內容

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

解決方案

上述通過幾個實際的例子說明了一些常見的報錯原因,下面來討論一下解決辦法。

先來看一下目前現網給出的比較多的解決辦法,具體怎么做就不展開了,一搜一大堆:

  1. 最原始的,環境變量添加項目根目錄進去
  • 這種方法比方法 2 好的就是不用寫那么多冗余代碼,但是可移植性太差了,不推薦
  1. 每次使用之前需要添加包的絕對路徑進去 sys.path.append('${absolute_path}')
  • 這種方法應該是比較多人使用的了,但是每次都要寫這堆代碼到文件頭上,太冗余了
  • 很容易被 IDE 格式化,比較不友好
  • 會欺騙 pylint 的檢查,梅開三度再吐槽一下 pylint

本文推薦的解決方案是虛擬環境 + pth 文件解決導入問題

首先來說一下使用虛擬環境的好處(可能還有些同學沒接觸,就啰嗦一下)

  1. 將項目環境與系統環境隔離,這樣安裝包的時候不會與系統環境的包發生沖突。尤其是在公用的機器上,系統環境的沖突往往引發一堆沒必要的問題
  2. 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 的路徑搜索問題

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容