前幾天,同事為專案的局部元件寫了一個偵錯小程式,我們姑且稱為debugger.py。該程式中會使用到整個專案的共同設定——DeployStatus,這些設定則放在專案下的configs模組(資料夾)裡,需要另外 import。

因此,debugger.py的開頭程式碼如下:

1
2
3
4
import os
import sys
...
from configs.config import DeployStatus

而專案的結構則是(這裡只凸顯兩者的「相對層級關係」,其餘細節省略):

1
2
3
4
5
6
7
.
├── 元件
│   ├── debugger.py

├── configs
│   ├── config.py

有經驗的你可能不用執行這個小程式就能預料——它找不到configs

不出所料,直接執行之後,會出現錯誤訊息:

1
ModuleNotFoundError: No module named 'configs'

為什麼?這牽涉到 Python 直譯器在 import 時,究竟「如何尋找 import 路徑」議題。


Python import 基礎

可先參考下列三篇文章,建立關於 import 的基礎世界觀:

從上述引用內容可知,debugger.py所以會出現ModuleNotFoundError,是因為from configs.config import DeployStatus這段程式碼的「程式寫作意圖」,是希望從「專案根目錄」的角度出發去 import——但實際上 Python 直譯器卻沒有如預期地這麼做。

sys.path

因為,在這個例子中,沒有額外設定的情況下,專案根目錄並不在sys.path裡(除非debugger.py就在專案根目錄底下,容後述),直譯器自然找不到configs模組。

換句話說,Python 直譯器在執行 import 時,會從sys.path中,由左至右依序嘗試要 import 的路徑。一般而言,sys.path的值可以例示如下:

1
['', '/usr/local/lib/python38.zip', '/usr/local/lib/python3.8', '/usr/local/lib/python3.8/lib-dynload', '/usr/local/lib/python3.8/site-packages']

第一個元素是空字串,代表當前目錄

兩個幫sys.path「加料」的方法

因此,若想要正確執行debugger.py,我們需要把「專案根目錄」的路徑,加入到sys.path,此時有兩個常見方法。

一、在程式碼中手動加入sys.path

也就是在from configs.config...之前,先手動加入這段sys.path.append('專案根目錄')

這樣的好處是,別人不需要額外設定PYTHONPATH,因為程式已經幫我們做完了。

而缺點則是——對我來說是一個缺點🐸——太醜了。不僅會降低程式碼的可讀性,且Flake8也會給出錯誤訊息(E402)

1
Module level import not at top of file

不過,在團隊協作時,這樣的做法可以減少大家在「設定未同步」時的潛在問題,所以還是要依不同情境考量,究竟要用哪種方式。

基於協作一致性考慮,本文的案例我仍建議同事採用這個做法。

二、使用PYTHONPATH

儘管如此,有時候我們只是個人開發,或不想在程式碼中直接修改sys.path,則設定PYTHONPATH是更常見的做法。


VS Code 設定PYTHONPATH

首先要說明的是,PYTHONPATH在不同情境會被不同的工具使用,比如 Dockerfile

而本文只集中在「使用 VS Code」的情境下——究竟要怎麼設定,才能讓 VS Code 取得正確的路徑?

嚴格說,是指 VS Code 的整合命令列中,如何正確套用PYTHONPATH。好讓 VS Code 在以整合命令列執行程式時,可以正確 import,而不會出現ModuleNotFoundError

通常這段設定會放在「專案」下的settings.json,也就是「專案/.vscode/settings.json」,你要使用「使用者」設定(全域的settings.json)也是可以,只是要留意不同專案是否會因此而有不一致的結果

設定PYTHONPATH的三種方式

關於 VS Code 加入PYTHONPATHsettings.json設定,從以前(至少是 3 年前)到現在,有過方式變遷,我們只需要知道:舊的方法已經不管用。

第一種方式:直接設定 settings.json(已廢棄)

1
"python.pythonPath": "專案根目錄"

簡單暴力,透過python.pythonPath這個 token,直接幫 VS Code 直接指定PYTHONPATH,但此 token 已經作古(被移除)了,可以不必理會。

第二種方式:透過 env 檔

你先建立 env 檔,讓 VS Code 去讀取它。不用說,env 裡面必須要有PYTHONPATH這個 key 才行。

通常我們會這樣設定 env 內容:

1
PYTHONPATH=$(pwd)

1
PYTHONPATH=.

可以看出,使用的是「相對路徑」或「環境變數」,兩者異曲同工,實際想指向的,都是專案根目錄。不用絕對路徑,則是為了「方便在不同專案之間套用」。

要特別注意,無論是.還是$(pwd),都是「相對於該 env 檔所在的資料夾」而言。

換句話說,如果你的 env「沒有」放在專案根目錄底下,這個設定就可能會出錯。此時為了避免過度複雜,改用絕對路徑也是可以的。


重要:sys.path 的第一順位值

需要補充說明的是,執行任意 Python 檔案時,Python 直譯器會依序嘗試sys.path裡的每個值,直到找到你要 import 的模組。而sys.path的「第一順位值」就是執行檔所在的路徑(資料夾)

By default, the interpreter looks for a module within the current directory. To make the interpreter search in some other directory you just simply have to change the current directory.

換句話說,如果debugger.py「正好」就在專案根目錄下,那就不需要額外的設定。因此,本文案例之所以會出現ModuleNotFoundError,也正因debugger.py不在專案的根目錄裡。

可想而知,把debugger.py改放在專案根目錄下,就不會有問題了。

但這並不是解決問題的根本方法。換句話說,我們不能只因為「方便」,就輕易改變原本設計好的專案結構。

這樣「妥協」的做法,往往會讓專案結構逐漸變得「缺乏一致性」而難以維護。


settings.json 設定 env 讀取路徑

有了 env 檔案,接著就是要讓 VS Code 去讀取它,settings.json要加入下列內容:

1
"python.envFile": "${workspaceFolder}/.env"

${workspaceFolder}的意思是「VS Code 當前開啟的專案根目錄」。

你可以把 env 檔放在更深層的資料夾,只要python.envFile的路徑正確,VS Code 仍然能夠正確讀取它。

但如前所述,env 中PYTHONPATH的值若為$(pwd).,那它的「實際路徑」就會隨著 env 所在的路徑而變動,有著不確定性。

所以,我們往往就是把 env 放在專案根目錄。當然,它通常叫「.env」。

第三種方式:「整合 terminal」設定

一開始看到ModuleNotFoundError時,我立刻想到的就是第二種設定,趕緊翻出筆記,依樣畫葫蘆。

沒想到,它不 work!

我開啟 VS Code 的整合命令列去執行debugger.py,發現它依舊找不到configs的路徑。這就奇了,我之前都是這樣做的啊?

後來想了一下,以前發生問題,主要是 VS Code 在對程式進行靜態分析時,會直接提示找不到路徑,所以我才透過python.envFile和 env 檔來解決。

而且這次是整合命令列的執行問題,兩者不盡相同。

但無妨,反正問題的本質不變,我們知道其中關鍵,就是PYTHONPATH設定,所以關鍵字打下去,不意外的——答案就在文件裡!

官方文件

使用「vscode pythonpath」關鍵字,你將輕易找到這篇「Using Python environments in VS Code」。

其中,和本文議題直接相關的,就是最下方的「Use of the PYTHONPATH variable」部分:

The PYTHONPATH environment variable specifies additional locations where the Python interpreter should look for modules. In VS Code, PYTHONPATH can be set through the terminal settings (terminal.integrated.env.*) and/or within an .env file.

本段的最大重點就是:PYTHONPATH環境變數除了使用 env 檔外,也可以直接由terminal.integrated這個 token 設定。

看到這裡,整個「故事背景」我們已經了解得差不多了,直接來看設定吧!

為 VS Code 的整合命令列設定PYTHONPATH

設定內容:

1
2
3
"terminal.integrated.env.linux": {
"PYTHONPATH": "${workspaceFolder}"
},

這個設定允許你,為「VS Code 整合命令列」額外新增環境變數,方便你在命令列中執行程式。

上述的env.linux可以改為env.osxenv.windows,讓你依不同作業系統設定不同變數內容。如果都一致,就可以像文件中那樣,使用萬字元env.*即可。

因為我只在 Linux VM 上執行,所以只設定了.linux

雖然文件表明,你也可以透過.env設定PYTHONPATH,但.env中的PYTHONPATH可能只對 linter、formatter 等工具有效,而不會影響 VS Code 的整合命令列。至少我無法透過.env 來解決命令列的執行問題。


特別提醒

terminal.integrated如它的名稱所述,只對 VS Code 的整合命令列有效:

However, in this case when the extension is performing an action that isn’t routed through the terminal, such as the use of a linter or formatter, then this setting won’t have an effect on module look-up.

舉例而言,如果你自行開一個 terminal 去執行debugger.py,則還是一樣會出現ModuleNotFoundError

如果這對你造成困擾,那麼採用「在程式碼加入sys.path」的方式,或許才是適合的選擇。