WeasyPrint lost Pango after a uv update — uv run strips DYLD_* variables on macOS

After a uv update, uv run stopped passing DYLD_FALLBACK_LIBRARY_PATH to Python on macOS. Re-executing with it in another variable lets WeasyPrint load Pango.

The WeasyPrint logo, the lowercase word weasyprint in dark navy between a green chevron and a green rounded rectangle, on a white card
On this page

Introduction

WeasyPrint on macOS finds Homebrew’s Pango only when DYLD_FALLBACK_LIBRARY_PATH points at /opt/homebrew/lib, and my article on building PDFs from Markdown set it in the Makefile in front of uv run. After I updated uv to 0.13.5, make docs-pdf failed with the error that line was written to fix:

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

Pango was still installed, and the Makefile had not changed. uv run was starting Python with every DYLD_* variable removed.

The fix passes the library directory in another environment variable, one that dyld (the macOS dynamic linker) does not touch, and the script copies it back into DYLD_FALLBACK_LIBRARY_PATH and re-executes itself before importing WeasyPrint. The Makefile keeps its single uv run line, and the build works the same with or without Homebrew.

Why Python started by uv run never sees DYLD_FALLBACK_LIBRARY_PATH

The variable was set, and uv dropped it before Python started. In the full error, dyld lists every directory it searched, but /opt/homebrew/lib is not among them:

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).

With the variable set, I started Python directly and ran a command, and Python sees the variable:

Terminal window
IN
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')))"
OUT
'/opt/homebrew/lib:'

With the variable still set, running the same command through uv run loses it:

Terminal window
IN
DYLD_FALLBACK_LIBRARY_PATH=/opt/homebrew/lib: uv run --no-project python -c "import os; print(repr(os.environ.get('DYLD_FALLBACK_LIBRARY_PATH')))"
OUT
None

The difference is that the uv binary is signed with the hardened runtime, and the hardened runtime scrubs DYLD_* variables from the process unless the binary carries the com.apple.security.cs.allow-dyld-environment-variables entitlement. codesign shows the flag on uv 0.13.5, and the binary carries no entitlements at all:

Terminal window
IN
codesign -dv ~/.local/bin/uv 2>&1 | grep flags
OUT
CodeDirectory v=20500 size=54552 flags=0x10000(runtime) hashes=1699+2 location=embedded
Where DYLD_FALLBACK_LIBRARY_PATH is lostA row of processes: make starts /bin/sh, which starts uv, which starts Python. /bin/sh sets the DYLD variable while starting uv, it is scrubbed when the hardened uv binary starts, and it is absent in Python, so loading libgobject fails.The recipe sets DYLD_FALLBACK_LIBRARY_PATHmakeruns the recipe/bin/shsets DYLD_* ✓uvhardened runtimeDYLD_* scrubbed ✗pythonno DYLD_* ✗dlopen fails:libgobject-2.0-0not found
/bin/sh sets the variable while starting uv, and it is gone once the hardened uv binary runs. Python inherits uv’s environment, so it never gets the variable either.

The hardened runtime removes DYLD_FALLBACK_LIBRARY_PATH as uv starts, so uv never has the variable to pass on to Python. I do not know which uv release started signing this way. The Makefile worked before I updated, and 0.13.5 strips DYLD_* variables.

The same problem is already reported in uvx invocation does not propagate DYLD_FALLBACK_LIBRARY_PATH (astral-sh/uv#6971). The report uses uvx, which is shorthand for uv tool run, and uv’s maintainers closed it as not planned. So the fix has to be a workaround in the Makefile and the script.

Set the variable inside Python and re-execute

Python itself is not hardened, so the directory only has to reach it in a variable whose name does not start with DYLD_. The Makefile passes the directory as DOCS_PDF_LIB_DIR, and the script turns it back into DYLD_FALLBACK_LIBRARY_PATH before WeasyPrint is imported, so dyld can find 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

Setting os.environ is not enough on its own. dyld reads DYLD_FALLBACK_LIBRARY_PATH once, when a process starts, so a value written into the running interpreter only reaches processes it launches later. The script therefore replaces itself with a fresh copy of the same interpreter, which starts with the variable already in place:

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 keeps the process ID. To uv, the process it started has not exited, so it does not delete the temporary environment, and the restarted interpreter can still load WeasyPrint from it.

How the re-executed Python gets DYLD_FALLBACK_LIBRARY_PATHmake starts /bin/sh, which starts uv. /bin/sh sets DOCS_PDF_LIB_DIR, which survives uv. uv starts one Python process and waits on its process ID. Inside that one process, Python first sets the DYLD variable and calls os.execv, which replaces it with a fresh Python that starts with the variable set and loads Pango. Because uv is still waiting, the temporary environment uv run --with built stays on disk, and the fresh Python loads WeasyPrint from it.The recipe sets DOCS_PDF_LIB_DIR, Python re-executesmakeruns the recipe/bin/shsetsDOCS_PDF_LIB_DIR ✓uvhardened runtimeDOCS_PDF_LIB_DIRkept ✓waits onPID 4242PID 4242 (one process)pythonsets DYLD_*, calls os.execvos.execv replaces itpythonstarts with DYLD_* set ✓, loads Pangouv run --with temporary environmentkept: uv has not exited ✓loadsWeasyPrint
DOCS_PDF_LIB_DIR, set by /bin/sh, passes through uv. Python replaces itself with os.execv inside the same process and restarts with DYLD_FALLBACK_LIBRARY_PATH set. uv is still waiting on that process, so the temporary environment stays, and the new Python loads WeasyPrint from it.

After Python replaces itself with os.execv inside the same process, the script runs from the top and calls _reexec_with_lib_dir() again. Without a marker, it would set the variable and re-execute again, forever. The function therefore sets _DOCS_PDF_REEXECED=1 before calling os.execv, and the restarted script sees it and returns immediately.

Only macOS needs this. On Linux, sys.platform is not darwin, so the function returns at once. On a Mac without Homebrew, brew --prefix in the Makefile finds nothing, so DOCS_PDF_LIB_DIR is empty and the function returns before touching anything. The same Makefile therefore works on machines running different operating systems.

Claude Code tested the re-exec through uv run with no DYLD_* variable set in the shell, and the restarted interpreter loaded libgobject-2.0.0.dylib from /opt/homebrew/lib. After that, make docs-pdf built the PDFs again on my machine.

Summary

After a uv update, DYLD_FALLBACK_LIBRARY_PATH set in front of uv run stopped reaching Python on macOS, and WeasyPrint failed to load Pango again. uv 0.13.5 is signed with the hardened runtime, which scrubs DYLD_* variables.

The library directory now travels as DOCS_PDF_LIB_DIR, and the script sets DYLD_FALLBACK_LIBRARY_PATH and re-executes itself before importing WeasyPrint. The Makefile keeps one uv run line, and the same build runs on Linux and on a Mac without Homebrew untouched.

If your dependencies live in a project environment, starting .venv/bin/python directly with the variable set is the simpler fix.

References

Share this article