前幾天,同事為專案的局部元件寫了一個偵錯小程式,我們姑且稱為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
2
3
4
5
6
7
[
'',
'/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.

這也符合了前述sys.path的第一個元素是空字串(即當前目錄)的說法。現在我們知道,所謂的當前目錄,就是執行檔所在的目錄

所謂的「當前目錄」

我們再參考 Python 官方文件中的說明:

The first entry in the module search path is the directory that contains the input script, if there is one. Otherwise, the first entry is the current directory, which is the case when executing the interactive shell, a -c command, or -m module.

從上可知,如果你在 terminal 中直接執行python debugger.py(有引數),那麼sys.path的第一順位值就是「包含 input script(debugger.py)的目錄」。

換句話說,如果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設定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」的方式,或許才是適合的選擇。


VS Code 自動套用 Python 虛擬環境 bug(與 pyenv 有關)

2024/04/25更新:今天在工作專案新增並設定 Python 版本,似乎確實正常了,為節省版面,本段內容先刪除,待日後更新 Django Tutorial 專案時也正常的話,會直接刪除本段。

2024/04/13更新:本段 bug 似乎已修正,目前僅供參考。我打算將在下次升級 Django-Tutorial 專案的 Python 版本時,將.python-version的內容加回來,並升級為 3.12.x,再觀察是否有問題。