最近在優化原項目一部分Python代碼,遇到了代碼重複拷貝的問題,一個方法拷貝了n多份,這個「壞味道」當然忍不了,準備將方法寫到utils.py裡,由於Python3已經支持相對路徑導入了,utils放到當前包的common目錄,用到此方法的代碼導入utils使用即可。so easy!
後來?後來我就掉進坑裡。
我以為的相對路徑導入並不是真實的相對路徑導入。
Python導入包或方法假設我們的工程項目是這樣的:
.
├── a
│ └── callee.py
├── b
│ └── caller.py
├── c
│ └── hello.py
└── main.py
常規操作hello.py中實現了一個列印「say hi~」方法hi():
# c/hello.py
def hi():
print("say hi~")現在想要在main.py中調用,那我們只需要加入一行from c.hello import hi,然後直接調用hi()即可。
# main.py
from c.hello import hi
hi()我們運行python3 main.py,正常輸出「say hi~」。
python和Java一樣都是用目錄管理包的,運行時會從當前路徑(main.py所在目錄)開始查找匹配的包名對應的c/hello.py文件,然後找到其中名為hi的方法,並調用。
import默認搜索順序默認情況下,python的import關鍵字會選擇優先查找python的內建模塊,若沒找到,則會去sys.path保存的路徑列表中尋找。
sys.path保存的路徑列表包括幾個部分:
第三方擴展的site-package目錄,也就是pip安裝第三方包的路徑相對路徑導入的那些坑現在有一個需求就是b目錄下的caller.py希望執行a目錄callee.py中的方法caller_test()方法,這個方法可以對應出調用者的信息。
# a/callee.py
import sys
import os
def caller_test():
"""列印調用者信息"""
back_frame = sys._getframe().f_back
if back_frame is None:
print("back_frame is None, no py caller!")
else:
back_filename = os.path.basename(back_frame.f_code.co_filename)
print("caller: {}".format(back_filename.split('.')[0]))python3已經可以支持相對路徑導入包了,簡單寫一下:
# b/caller.py
import sys
from ..a import callee
def call():
print('- caller.py -')
print("name: {}".format(__name__))
callee.caller_test()
if __name__ == '__main__':
call()這裡可以看到a包名前額外多了兩個點..,按照python手冊中關於相對導入的介紹:兩個點..表示從當前目錄的父目錄開始查找a/callee.py文件,一個點.表示當前目錄,那麼如果我想找父目錄的父目錄中的包呢?那就用三個點...,通常用到三個點的情況並不多。
看上去毫無問題,正常極了,一運行就傻眼了。
錯誤1執行./b/caller.py,提示錯誤:ImportError: attempted relative import with no known parent package。
嘗試在import前一行加入列印__name__、__package__、sys.path,結果如下:
name: __main__
package: None
sys.path: ['/home/rfw/test/b', '/usr/lib/python38.zip', '/usr/lib/python3.8', '/usr/lib/python3.8/lib-dynload', '/home/rfw/.local/lib/python3.8/site-packages', '/usr/local/lib/python3.8/dist-packages', '/usr/lib/python3/dist-packages']很奇怪,看到sys.path中當前路徑是b目錄所在路徑,按照相對導入的邏輯,..a就應該進入了test/a目錄才對!
錯誤2StackOverflow上查了下,可以使用python -m b.caller以模塊的方式運行,將包信息告訴python解釋器。
嘗試了下,這次錯誤提示變了,ValueError: attempted relative import beyond top-level package,提示是說相對導入找到的路徑已經超過最頂級的了。
此時再次列印,錯誤日誌如下:
name: __main__
package: b
sys.path: ['/home/rfw/test', '/usr/lib/python38.zip', '/usr/lib/python3.8', '/usr/lib/python3.8/lib-dynload', '/home/rfw/.local/lib/python3.8/site-packages', '/usr/local/lib/python3.8/dist-packages', '/usr/lib/python3/dist-packages']這時,和上一次列印不一樣的地方在於__package__的值為b,當前運行路徑為test目錄。
由於顯示當前目錄是test,因此,嘗試把導入改成from a.callee import caller_test,運行正常!列印如下:
name: __main__
package: b
sys.path: ['/home/rfw/test', '/usr/lib/python38.zip', '/usr/lib/python3.8', '/usr/lib/python3.8/lib-dynload', '/home/rfw/.local/lib/python3.8/site-packages', '/usr/local/lib/python3.8/dist-packages', '/usr/lib/python3/dist-packages']
caller: caller但是這就不是相對導入了啊。百思不得其解。
真相只有一個查了下python官方文檔關於相對導入的說明(https://www.python.org/dev/peps/pep-0328/ ),恍然大明白。
Relative imports use a module's __name__ attribute to determine that module's position in the package hierarchy. If the module's name does not contain any package information (e.g. it is set to '__main__') then relative imports are resolved as if the module were a top level module, regardless of where the module is actually located on the file system.
翻譯過來就是:
相對導入依賴於一個模塊的__name__屬性,根據這個屬性去決定該模塊在整個包中的層級結構。
當一個模塊的__name__屬性不包含任何包信息時,如直接運行py腳本時,__name__會被設置成__main__,這時,不管這個文件位於包目錄的哪個位置,相對導入機制會把當前腳本視為頂級模塊。
這就意味著,只要是我從終端運行python腳本,都會遇到__name__為__main__的問題,當前被運行的python腳本永遠無法使用相對導入。
現在在根目錄下修改main.py,並在b/b1目錄下創建caller_proxy.py。
.
├── a
│ └── callee.py
├── b
│ ├── b1
│ │ └── caller_proxy.py
│ └── caller.py
├── c
│ └── hello.py
└── main.pymain.py的內容如下:
import sys
from c.hello import hi
print("__name__: {}, __package__: {}".format(__name__, __package__))
from b.b1 import caller_proxy
caller_proxy.proxy()caller_proxy.py的內容如下:
import sys
from .. import caller # 相對導入
print(__package__)
def proxy():
print("- caller_proxy.py -")
print("name: {}".format(__name__))
caller.call()該文件使用了相對導入,現在運行./main.py,結果如下。
__name__: __main__, __package__: None
say hi~
- caller_proxy.py -
name: b.b1.caller_proxy
- caller.py -
name: b.caller
caller: caller這時,caller_proxy.py執行時的__name__值是正常的包名結構b.b1.caller_proxy,因此可以使用相對導入..找到b.caller。
而caller.py執行時的包名結構是b.caller,因此,相對導入只能找到b包下的文件,所以,只能使用from a.callee import caller_test。
通常應該怎麼做為了避免一些奇奇怪怪的問題,還是比較推薦在sys.path數組追加要導入包絕對路徑的方式。
import os
import inspect
sys.path.append(os.path.realpath(os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '../common')))
from utils import xxx_func以之前的caller.py為例,想要調用a/callee.py,可以寫成:
import sys
import os
import inspect
sys.path.append(os.path.realpath(os.path.join(os.path.dirname(inspect.getfile(inspect.currentframe())), '../a')))
from callee import caller_test
def call():
print('- caller.py -')
print("name: {}".format(__name__))
caller_test()
if __name__ == '__main__':
call()這樣就不用care是直接運行,還是用-m參數以模塊去運行了,直接運行./b/caller.py,輸出結果如下:
$ ./b/caller.py
- caller.py -
name: __main__
caller: caller
以上,就是之前處理Python import導入包時遇到的坑,簡單記錄。