前幾天,同事為專案的局部元件寫了一個偵錯小程式,我們姑且稱為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/13更新:本段 bug 似乎已修正,目前僅供參考。我打算將在下次升級 Django-Tutorial 專案的 Python 版本時,將.python-version的內容加回來,並升級為 3.12.x,再觀察是否有問題。

2024/01/21更新本段內容。

相關文章:Poetry + pyenv 教學:常用指令與注意事項

說是 bug 可能也不盡準確,但我在使用 VS Code 的整合命令列時,發現一個小問題。

這個問題,會讓你在使用 VS Code 的整合命令列時,出現ModuleNotFoundError。但其它情況不會,因為只有 VS Code 才有自動套用虛擬環境的功能。

平常我們開啟 VS Code Python 專案,IDE 都會先要求你要某選擇一個 Python 環境,我們通常是選擇專案虛擬環境。

下次你再打開 VS Code 時,它會記得你的選擇,並自動套用。而且打開整合 terminal 時,也會自動切換到專案虛擬環境。

這絕對是非常方便且必要的功能!畢竟開發中「反覆重啟」整合命令列的頻率很高,若每次都要手動切換虛擬環境,也太麻煩了。

但我發現在在我的某個專案失效了。應該說,雖然有切換,但功能不健全

問題說明

舉個例子,我在某專案透過pyenv local設定專案要用 Python 3.11.7 版本,指令會在專案中產生一個.python-version檔,內容就是:

1
3.11.7

然後在專案根目錄下,以poetry env use 3.11指令建立 Python 虛擬環境。注意,此時 pyenv 中的 global 版本仍是 3.10.11。

這裡的一個重點是:專案虛擬環境和 pyenv global 的 Python 版本「並不一致」

現在,用 VS Code 開啟專案,並打開整合命令列,你會發現,使用虛擬環境中的套件沒問題,比如blackflake8等,都能正常運作。

但當我執行python manage.py runserver這樣的指令時,就會出現錯誤:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
❯ python manage.py runserver
Traceback (most recent call last):
File "/home/kyo/Django-Tutorial/manage.py", line 11, in main
from django.core.management import execute_from_command_line
ModuleNotFoundError: No module named 'django'

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
File "/home/kyo/Django-Tutorial/manage.py", line 22, in <module>
main()
File "/home/kyo/Django-Tutorial/manage.py", line 13, in main
raise ImportError(
ImportError: Couldn't import Django. Are you sure it's installed and available on your PYTHONPATH environment variable? Did you forget to activate a virtual environment?

它竟然說「ModuleNotFoundError: No module named 'django'」,這就奇了,明明確實啟動了虛擬環境,而且django也安裝在虛擬環境中了。

而且使用別的套件指令,完全正常,這個問題八成是出在 Python 的路徑設定上。

問題定位

我大概觀察了一下,此時sys.path的中並沒有.venv虛擬環境中的 Python 執行檔所在目錄,即<專案路徑>/.venv/bin/

但這也合理,因為虛擬環境中的 Python,其實是以軟連結的方式存在。連結目標仍是 pyenv 中的 Python 3.11.7。

1
2
3
lrwxrwxrwx 1 kyo kyo   47 Jan  5 06:19 python -> /home/kyo/.pyenv/versions/3.11.7/bin/python3.11*
lrwxrwxrwx 1 kyo kyo 6 Jan 5 06:19 python3 -> python*
lrwxrwxrwx 1 kyo kyo 6 Jan 5 06:19 python3.11 -> python*

這應該是 pyenv 的設計,因為它不想每個專案都複製一份 Python 執行檔,所以只是建立一個軟連結而已。

雖然發生問題原因的細節我不太清楚,但這應該屬於工具之間的「協調問題」

附帶一提,如果專案的 pyenv local 版本和 global 版本一致,則不會出現這個問題。


解決方法

解決方法很簡單,有兩種。

一、在 VS Code 的整合命令列中「手動切換」到專案虛擬環境

主動執行poetry shell,重新啟動一次虛擬環境後,上述指令就會正常執行。

這在一般命令列本來就是如此,因為一般命令列你本來就要自己啟動虛擬環境。但在 VS Code 中,這個方法顯然不是我們想要的。

注意:VS Code 整合命令列,一開始時虛擬環境確實有自動啟動,因此,所有套件的指令都能使用。

只是因為.python-version對 pyenv 尋找 Python 路徑的影響,Python 的路徑存在問題,所以像python manage.py runserver這樣的指令就會出錯。

二、清空.python-version檔案內容

把專案中因為pyenv local而產生的.python-version檔案內容清空!

注意,是清空,不能刪除。

刪除的話,pyenv 會選回 global 版本,這絕不是我們期望的行為。

再重新開啟 VS Code 整合命令列,你會發現問題已經解決了。

這個 commit 就是我在專案中採用這個方法的例子,供參。