我更新 uv 之後 WeasyPrint 就載入不到 Pango 了 — macOS 上的 uv run 會移除 DYLD_* 變數

更新了 uv 之後,macOS 上的 uv run 便不再把 DYLD_FALLBACK_LIBRARY_PATH 傳給 Python。只要改用另一個環境變數傳遞函式庫路徑,再讓指令碼重新執行自己,WeasyPrint 就能再次載入 Pango。

白色卡片上的 WeasyPrint 標誌,深藍色的小寫 weasyprint 字樣夾在綠色角括號與綠色圓角長方形之間
本頁目錄

引言

macOS 上的 WeasyPrint 只有在 DYLD_FALLBACK_LIBRARY_PATH 指向 /opt/homebrew/lib 時,才找得到 Homebrew 的 Pango。我在用 Markdown 產生 PDF 的那篇文章裡,把這個變數設定在 Makefile 的 uv run 前面。但是當我把 uv 更新到 0.13.5 之後,make docs-pdf 又出現了找不到函式庫的錯誤:

OSError: cannot load library 'libgobject-2.0-0': dlopen(libgobject-2.0-0, 0x0002): tried: 'libgobject-2.0-0' (no such file)

Pango 還裝著,Makefile 也沒有改過。問題出在 uv run,它在啟動 Python 時把所有 DYLD_* 變數都移除了。

解決方法是改用另一個環境變數來傳遞函式庫目錄,dyld(macOS 的動態連結器)不會處理這個變數。指令碼再把函式庫目錄複製回 DYLD_FALLBACK_LIBRARY_PATH,並在匯入 WeasyPrint 之前重新執行自己。Makefile 仍然只有一行 uv run,不論有沒有 Homebrew,建置都一樣能夠運作。

為什麼透過 uv run 啟動的 Python 讀不到 DYLD_FALLBACK_LIBRARY_PATH?

變數確實有設定,只是在 Python 啟動之前就被 uv 移除了。在完整的錯誤訊息裡,dyld 列出了它搜尋過的每一個目錄,但其中並沒有 /opt/homebrew/lib。

OSError: cannot load library 'libgobject-2.0-0': dlopen(libgobject-2.0-0, 0x0002): tried: 'libgobject-2.0-0' (no such file), '/System/Volumes/Preboot/Cryptexes/OSlibgobject-2.0-0' (no such file), '/Users/<user>/.local/share/uv/python/cpython-3.14.9-macos-aarch64-none/lib/libgobject-2.0-0' (no such file), '/usr/lib/libgobject-2.0-0' (no such file, not in dyld cache), 'libgobject-2.0-0' (no such file).

我在設定了變數的情況下,直接啟動 Python 並執行了指令,這時是看得到這個變數的:

終端機視窗
輸入
DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib: ~/.local/share/uv/python/cpython-3.14-macos-aarch64-none/bin/python3 -c "import os; print(repr(os.environ.get('DYLD_FALLBACK_LIBRARY_PATH')))"
輸出
'/opt/homebrew/lib:'

同樣在設定了變數的情況下,透過 uv run 啟動並執行同一個指令時,變數卻不見了:

終端機視窗
輸入
DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib: uv run --no-project python -c "import os; print(repr(os.environ.get('DYLD_FALLBACK_LIBRARY_PATH')))"
輸出
None

差別在於 uv 執行檔是用 hardened runtime 簽署的。除非執行檔帶有 com.apple.security.cs.allow-dyld-environment-variables 這個 entitlement,否則 hardened runtime 會把行程中的 DYLD_* 變數移除。用 codesign 查看 uv 0.13.5,可以看到 runtime flag,而且這個執行檔沒有任何 entitlement:

終端機視窗
輸入
codesign -dv ~/.local/bin/uv 2>&1 | grep flags
輸出
CodeDirectory v=20500 size=54552 flags=0x10000(runtime) hashes=1699+2 location=embedded
DYLD_FALLBACK_LIBRARY_PATH 在哪裡消失圖中是一排行程:make 啟動 /bin/sh,/bin/sh 啟動 uv,uv 再啟動 Python。/bin/sh 在啟動 uv 時設定了 DYLD 變數,但在啟用 hardened runtime 的 uv 啟動時被移除了,Python 拿不到,所以載入 libgobject 失敗。在 recipe 裡設定 DYLD_FALLBACK_LIBRARY_PATHmake執行 recipe/bin/sh設定 DYLD_* ✓uvhardened runtimeDYLD_* 被移除 ✗python沒有 DYLD_* ✗dlopen 失敗:找不到libgobject-2.0-0
/bin/sh 在啟動 uv 時設定了變數,但啟用 hardened runtime 的 uv 執行檔一開始執行,變數就被移除了。Python 繼承了 uv 的環境,所以也拿不到這個變數。

hardened runtime 在 uv 啟動時就移除了 DYLD_FALLBACK_LIBRARY_PATH,所以 uv 根本沒有拿到這個變數,也就無從傳給 Python。我不知道 uv 從哪個版本開始改用 hardened runtime 簽署,只知道更新之前 Makefile 是正常的,而 0.13.5 會移除 DYLD_* 的相關變數。

uv 的 issue 裡已經有人回報過同樣的問題 (astral-sh/uv#6971)。回報時用的是 uvx(也就是 uv tool run),但 uv 的維護者以「不打算處理」(not planned)將它關閉了。因此只能想辦法在 Makefile 和指令碼裡繞過這個限制。

在 Python 裡設定變數並重新執行

Python 本身沒有啟用 hardened runtime,所以只要改用名稱不是 DYLD_ 開頭的環境變數,就能把函式庫目錄送進 Python。Makefile 用 DOCS_PDF_LIB_DIR 傳遞目錄,指令碼再在匯入 WeasyPrint 之前把這個值轉回 DYLD_FALLBACK_LIBRARY_PATH,讓 dyld 找得到 Pango。

Makefile
BREW_PREFIX := $(shell brew --prefix 2>/dev/null)
docs-pdf:
DOCS_PDF_LIB_DIR="$(if $(BREW_PREFIX),$(BREW_PREFIX)/lib,)" \
uv run --with markdown --with weasyprint python scripts/docs/build_docs_pdf.py

光是設定 os.environ 還不夠。dyld 只會在行程啟動時讀取一次 DYLD_FALLBACK_LIBRARY_PATH,所以在執行中的直譯器裡寫入這個值,只會影響它之後啟動的行程。因此指令碼會重新啟動同一個直譯器來取代自己,這樣新的直譯器在啟動時就會帶有這個變數:

scripts/docs/build_docs_pdf.py
import os
import sys
def _reexec_with_lib_dir() -> None:
lib_dir = os.environ.get("DOCS_PDF_LIB_DIR")
if sys.platform != "darwin" or not lib_dir or os.environ.get("_DOCS_PDF_REEXECED"):
return
current = os.environ.get("DYLD_FALLBACK_LIBRARY_PATH", "")
os.environ["DYLD_FALLBACK_LIBRARY_PATH"] = f"{lib_dir}:{current}" if current else lib_dir
os.environ["_DOCS_PDF_REEXECED"] = "1"
os.execv(sys.executable, [sys.executable, *sys.argv])
_reexec_with_lib_dir()
import markdown # noqa: E402
from weasyprint import CSS, HTML # noqa: E402

os.execv 會保留行程 ID。對 uv 來說,它啟動的行程還沒有結束,所以不會刪除臨時環境。重新啟動的直譯器因此還能從 uv run --with 的臨時環境載入 WeasyPrint。

重新執行的 Python 如何拿到 DYLD_FALLBACK_LIBRARY_PATHmake 啟動 /bin/sh,/bin/sh 啟動 uv。/bin/sh 設定的 DOCS_PDF_LIB_DIR 能夠通過 uv。uv 啟動一個 Python 行程,並等待這個行程 ID 結束。在同一個行程裡,Python 先設定 DYLD 變數並呼叫 os.execv,被一個啟動時就帶有這個變數的新 Python 取代,然後載入 Pango。因為 uv 還在等待,uv run --with 建立的臨時環境仍留在磁碟上,新的 Python 就從這個環境載入 WeasyPrint。在 recipe 裡設定 DOCS_PDF_LIB_DIR,由 Python 重新執行make執行 recipe/bin/sh設定DOCS_PDF_LIB_DIR ✓uvhardened runtimeDOCS_PDF_LIB_DIR保留 ✓等待PID 4242 結束PID 4242(同一個行程)python設定 DYLD_* 後呼叫 os.execvos.execv 取代自己python啟動時已帶有 DYLD_* ✓,載入 Pangouv run --with 的臨時環境uv 還沒結束,所以保留 ✓載入WeasyPrint
/bin/sh 設定的 DOCS_PDF_LIB_DIR 能夠通過 uv。Python 在同一個行程裡用 os.execv 取代自己,重新啟動時就帶有 DYLD_FALLBACK_LIBRARY_PATH。uv 還在等待這個行程,所以臨時環境仍然保留,新的 Python 能從臨時環境載入 WeasyPrint。

當 Python 在同一個行程裡用 os.execv 取代自己並重新執行之後,指令碼會從頭執行,再呼叫一次 _reexec_with_lib_dir()。如果沒有任何標記,指令碼就會再次設定變數並重新執行,無限循環下去。所以函式在呼叫 os.execv 之前,會先設定 _DOCS_PDF_REEXECED=1,重新啟動的指令碼看到這個變數就會直接返回。

只有 macOS 需要這個處理。在 Linux 上,sys.platform 不是 darwin,函式會直接返回。在沒有 Homebrew 的 Mac 上,Makefile 裡的 brew --prefix 什麼都拿不到,DOCS_PDF_LIB_DIR 是空的,所以函式同樣會在做任何修改之前就返回。這樣一來,同一個 Makefile 在不同 OS 的機器上都能使用。

Claude Code 在 shell 裡完全不設定 DYLD_* 變數的情況下,透過 uv run 測試了重新執行。重新啟動的直譯器從 /opt/homebrew/lib 載入了 libgobject-2.0.0.dylib。之後,make docs-pdf 在我的機器上也重新建置出了 PDF。

總結

我在 macOS 上把 DYLD_FALLBACK_LIBRARY_PATH 設定在 uv run 前面,但更新 uv 之後,這個變數就不再傳到 Python,導致 WeasyPrint 載入不到 Pango 了。這是因為 uv 0.13.5 使用 hardened runtime 簽署,而 hardened runtime 會移除 DYLD_* 變數造成 Pango 無法被載入。

現在函式庫目錄改用 DOCS_PDF_LIB_DIR 傳遞,指令碼會設定 DYLD_FALLBACK_LIBRARY_PATH,並在匯入 WeasyPrint 之前重新執行自己。Makefile 仍然只有一行 uv run,同一套建置在 Linux 和沒有 Homebrew 的 Mac 上,不用任何修改也能執行。

如果相依套件放在專案環境裡,設定好變數後直接啟動 .venv/bin/python 是更簡單的解決方法。

參考連結

分享這篇文章