聊聊Python包和相對導入

renyuneyun 2020年05月29日(週五) 1 mins

今天嘗試將一個代碼庫拆分成若干模塊(準確用語應該是「」),目標可以說既典型又不典型:從邏輯上說,代碼分爲三塊,其中一塊是共享的。

很理所當然的,我會將其拆分爲A、B、C三個目錄,然後試圖從A和B中導入C。於是也很理所當然的,我期望可以直接進行相對導入。然而這時候就有了問題,也是我走了許多彎路,浪費了很多時間的原因所在。

爲了避免自己再犯錯,也爲了以後可以隨手發給別人,寫這麼一篇簡要但不減省地介紹和解釋一下Python導入相關的話題。

我的需求

首先,我的大前提就是不要去魔改sys.pathPYTHONPATH環境變量。

抽象出來,我的目標目錄結構大概是這樣:

my_code
├── common
│   └── shared.py
├── server
│   ├── app.py
│   ├── lib1.py
│   └── lib2.py
└── user_lib
    └── lib.py

其中server/user_lib/都會用到common/包的內容。理想的模式([1])似乎是將三個包獨立出來成爲三個庫,然後分別加入。但由於一些其他因素,目前不方便如此拆分。

特別地,server/下的app.py需要可以在任意地方執行;而user_lib/也有類似的要求(導入)。

官方標準

在進入例子之前,首先當然要提到「導入」相關行爲的官方標準。不然折騰半天最後發現是自己沒有正確按照標準來,那豈不冤枉?

官方標準其實不多,而且多是Python 2時代就已經建立起來的。其中最主要的是PEP 328PEP 366:PEP 328解釋了Python如何處理(絕對和相對)導入以及曾經的其他建議;PEP 366介紹了後來新增的修正,主要目的是前向兼容地提供對主模塊內使用相對導入的支持。

當然,還有一個關於隱式包的標準談了一些特殊情況。但一來我的需求下沒法用隱式包,二來它最終還是要按前面兩個進行的。

按PEP 328的說法,導入就只分絕對導入和相對導入。絕對導入就是按完整的包路徑去尋找,而相對導入就是導入相對當前包的另一個包(的內容)。 自Python 3(3.2?)開始,(除隱式包內的導入以外)相對導入便要求必須顯式進行,也就是說要使用from A import B的形式——其中A是一個相對路徑,也就是以.打頭的包路徑(如.A)。包的相對路徑的規定和Unix路徑的習慣類似:一個點表示當前包,兩個表示上一級包,三個表示再上一級,依此類推。

那麼絕對導入要怎麼尋找包呢?通過sys.path。在shell中設置的PYTHONPATH這個環境變量最終會被加入sys.path變量中。同時,它還會加入一些其他的目錄,比如(典型地)/usr/lib/pythonXXX/site-packages(其中pythonXXX和你的Python解釋器版本相對應)。 Python會在sys.path下進行尋找,查找是否有對應的包。一般而言,就是去尋找對應的子目錄。

標準看起來很簡單很美好,但使用中卻有一些奇奇怪怪的坑。

簡單分離及錯誤

於是在看了標準後,很理所當然地,我按前面所說的目錄結構將文件重新組織,順便在每個目錄touch __init__.py。然後我便開始逐一修改import語句,顯式進行相對導入。最終形成的目錄結構如下:

my_code
├── common
│   ├── __init__.py
│   └── shared.py
├── __init__.py
├── server
│   ├── app.py
│   ├── __init__.py
│   ├── lib1.py
│   └── lib2.py
└── user_lib
    ├── __init__.py
    └── lib.py

於是在server/包中,我有了這樣的導入語句:

server/lib1.py

from . import lib2
from ..common import shared

server/app.py

from . import lib1

於是直接執行app.py,在導入出現報錯:

ImportError: attempted relative import with no known parent package

看到這句報錯的我完全摸不到頭腦,因爲在我的理解中這個包顯然有parent package。我搜到了這篇介紹如何將包拆分的文章,裏邊也談到了如何放置和導入共享的包。但我大略一看,我和它的做法一致,但它似乎沒有提到會遭遇障礙的事情。我於是直接搜報錯,看到了這篇譯文明確說了如何處理該問題:

解決方案有兩個,分別是在更上一級目錄創建可執行文件來執行,和在更上一級目錄通過python -m my_code.server.app執行。

仔細想想,這兩個方案似乎是一樣的,都是在更上一級目錄中進行執行,(自動)修改執行時的變量信息,使得parent package存在。

於是理所當然地,方案2必須要用python -m XXX的形式執行,而不能是同樣在更上一級目錄執行python XXX

Python如何處理包名稱和結構

爲了更進一步理解爲什麼上述方案可行而我原來的方案不行,我又翻閱了更多文檔進行了更多測試,最終形成了一些較爲系統的認知。很奇怪的是,官方標準或官方文檔並沒有對這些事情進行明確提醒(或是不夠易找)。當然,本節內容有部分是我個人理解,若有錯誤還望指正。

當一個目錄下有__init__.py文件時,Python會將其當作一個包來處理。如果沒有,則按隱式包來處理(參考文檔即可,這裏不詳細討論)。在__init__.py中我們可以控制向外暴露的包結構和內容,參考這裏

按照PEP 366所說,Python識別包名稱是通過__package__字符串。該變量由解釋器自動設置,的內容是通過點分隔的完整包路徑(或None)。

如果直接使用Python解釋器去執行一個源文件(如對該文件增加可執行屬性或顯式調用解釋器執行文件),那麼__package__ == 'None',所以就會出現上述報錯。而如果將其當作模塊執行,如python -m my_code.server.app,則該變量會和命令中的一致,故而會被按預期設定。

我在測試中嘗試過在該變量中增加完整的包路徑,但發現這樣無法起到預期作用,因爲Python解釋器依然報告不知道其父包在哪——似乎是因爲不會自動回溯文件系統去尋找上級目錄。其實PEP 366已經提到了這點,且進行了完整說明:若需讓此方案奏效,還需要修改sys.path,使目標包可以被識別。

增加幾行print可以發現,__init__.py文件是在訪問包時必然會首先打開並執行的。同時可以發現,__package__僅在模塊(文件)內生效,於是無法通過修改某一文件的該變量來節約掉修改其他文件的功夫。另外,__main__.py文件除了會被當作包的可執行入口外,並沒有其他特殊之處,尤其不會修改任何的其他定義。

方案缺陷

然而如果本文停在這裏,那麼其實在一定程度上來說我其實並沒有說清問題。

爲什麼這麼說呢?因爲該方案雖然可用,但在使用中有諸多不便。其中最要命的,就是必須要使用模塊方式執行,也就是python -m my_code.server.app。這意味着什麼呢?

這首先意味着不能直接執行文件,其次還意味着不能在其他模塊/程序中導入這些包。

這就要命了,因爲我折騰這麼半天,其中一個目的就是希望user_lib/目錄下的東西可以被別的項目使用呀。既然是在別的項目,那麼當然不能在別的項目中import path.to.my_code.user_lib,而要可以import user_libimport my_code.user_lib

那麼,爲什麼即使我的sys.path下已經可以找到my_code,但我還是不能在其他項目導入呢?還是因爲向「上級」的引用:

user_lib/lib.py

from ..common import shared

當在其他項目(X)導入的時候,由於「當前」包是X的包,所以user_lib/lib.py對「上級」的尋找會出問題,無法按我們預期中的尋找到它「所在目錄的上級目錄」。

完善解法

既然我們已經找到問題所在,那麼解決方案也呼之欲出了:讓它可以找到「上級」就好了。

於是方案分爲兩種:

  1. 告訴它它的上級包是誰
  2. 改用絕對導入

方案一在理念上比較直接,但實操上比較麻煩,需要在每個(需要相對導入的)文件中手動修改其__package__變量爲想要的值。

方案二在理念上比較麻煩,但實操上比較簡單,需要將包添加到sys.path中(比如修改PYTHONPATH或進行「editable安裝」),同時修改對上級的導入爲絕對導入。

我比較不喜歡實操麻煩的東西,所以我最終選了方案二。而且由於我使用pipenv進行管理,所以實操中更簡單了:(在修改好源代碼後)只要在上級目錄寫一份極其簡單的setup.py,然後在那執行pipenv install -e .就好了。

注意到我是在上級目錄寫的setup.py,而非my_code/目錄。我不確定這是否是必須的,但我目前暫時沒有研究setup.py,所以相當於照搬了別人例子

心得小結

這一套走下來其實花了我很多時間,尤其是不斷試錯的過程。究其本源,這還是因爲Python是一個腳本語言,所以很多東西都是按腳本的模式走的。

上面一串下來,給我的概念就是:凡是直接暴露給用戶的東西,都不應該使用相對導入;但在該東西內部,則可以相對導入到至多到此爲止。

如PEP 366中所說,其實有別的更好的方法,但爲了「兼容」,不得不只做出這種修改。相對而言,我反而是更寧願放棄兼容,而將之修改得更「一致」。比如Rust 2018在模塊和導入部分的新設計就很清晰,我很樂意去修改已有的代碼來適應這種變更(而且只要你不編輯Cargo.toml說「我要上Rust 2018」,還可以不立即修改代碼,有個緩衝階段)——當然,我沒有試過在類似場景下使用Rust,不清楚是否也有類似的坑(但按說一個編譯型的語言應該不會有,畢竟大不了編譯成動態鏈接庫)。

另外,我在測試中發現,Python對包名稱的處理其實就是不處理。這導致包名最好不要帶短橫線(-)——不然它沒法寫在import語句中(當然你可以通過更迂迴的方法導入)。這點在一些其他語言中其實是做了不同處理的。比如說Golang直接在導入那裏用字符串,繞過這個問題;Lisp系列支持用短橫線做關鍵字,所以導入沒有一點毛病;Rust似乎是會自動調整:文件系統中使用短橫線(-)時,導入指令依然可以使用下劃線(_)(不過我一直沒細究如果文件系統中同時存在短橫線和下劃線兩種時,導入會發生什麼)。

在寫作快要完成時發現,這個問題的這篇回答其實已經完整呈現了上文的所有解法。但這篇回答其實也沒解釋問題所在,只是說了「題目中的現象」如何產生,以及提供了解決方案。


您可以在Hypothesis上的該群組內進行評論,或使用下面的Disqus評論。