nix-python-pythonpath-sitecustomize-shim¶
Overlays
Runtime-patch a Nix-built Python application without patching its source, by
injecting a sitecustomize.py onto the interpreter's PYTHONPATH.
The problem¶
You build a Python app in Nix (via buildPythonApplication, a flake's builder,
etc.). Your nixpkgs bumps a shared library — say transformers, torch, or any
fast-moving dependency — but the app (or a model package it loads) still targets
the older API shape. The app crashes at runtime on a changed signature, a moved
attribute, or a tensor-shape mismatch.
You don't want to fork the app, carry a patch file that rots against every upstream release, or pin the whole dependency stack backwards. You want a small, surgical, defensive fix that lives entirely in your overlay.
The insight¶
CPython auto-imports a module named sitecustomize at interpreter startup,
before your app's main ever runs — if it can find one on sys.path. So if you:
- write your patch into a
sitecustomize.py, and - put the directory containing it first on the app wrapper's
PYTHONPATH,
then your code runs first, every time, on every invocation of that wrapper — a free "before-main" hook with zero source changes.
In Nix this is two moves:
writeTextDir "sitecustomize.py" <body>— produces a store path that is a directory containing exactlysitecustomize.py.postFixup+substituteInPlace ... --replace-fail— rewrite the one line in the app'sbin/wrapper that setsexport PYTHONPATH='...', prepending the shim directory.
${appName} = basePackage.overrideAttrs (old: {
postFixup = (old.postFixup or "") + ''
substituteInPlace $out/bin/${appName} \
--replace-fail "export PYTHONPATH='" "export PYTHONPATH='${compatShim}:"
'';
});
Traps and why the details matter¶
-
Use
--replace-fail, not--replace. If a future version of the app changes its wrapper format, the anchor string disappears and the build fails loudly instead of silently shipping a package with no shim — exactly the failure mode you want for a patch that is easy to forget about. -
Order: the shim dir goes FIRST.
sitecustomizeresolves to the earliest match onsys.path, and any module the shim ships shadows the app's copy. Prepend (${compatShim}:), never append. -
Wrap every patch in a bare
try/except. A monkeypatch that throws at import time takes the whole interpreter down at startup — strictly worse than the bug you were fixing. Each patch must fail closed and let the app boot. -
Patch lazily-imported modules via an
__import__wrapper. If the module you need to patch is imported late (aftersitecustomizeruns), you can't reach it at startup. Wrapbuiltins.__import__so the patch is re-attempted after every import and applies the instant your target lands insys.modules. Guard it with a re-entrancy flag and analready-patchedmarker so it runs exactly once and never recurses.
Bonus: build a torch-family package against the installed torch¶
The same file shows a related overlay trick. A package like torchaudio pins a
specific torch version. If your nixpkgs ships a different torch, building
torchaudio's pinned source against the installed torch's build inputs fails
(ABI / missing CUDA headers such as cusparse.h).
Fix it by overriding only torchaudio's src + version to match the torch
you actually have, leaving torch itself untouched so triton/torch store
paths don't fork:
torchaudio = pyPrev.torchaudio.overridePythonAttrs (_: {
version = "2.11.0"; # match your installed torch
src = final.fetchFromGitHub {
owner = "pytorch"; repo = "audio"; tag = "v2.11.0";
hash = "sha256-..."; # nix-prefetch-github pytorch audio --rev v2.11.0
};
});
How to use¶
- Copy
default.nixinto your overlays. It is a function that returns an overlay: apply it to itsappNameargument first, and the result (final: prev: ...) is the overlay you register — e.g.nixpkgs.overlays = [ (import ./default.nix { appName = "myapp"; }) ];.appNameis the name of the package attribute to patch and of itsbin/<name>wrapper. - Replace the placeholder
basePackagewith your real app derivation. - Rewrite the
sitecustomize.pybody (compatShim) with the patches your version skew actually needs. The two examples in the file — a signature-compat wrapper and a late-import monkeypatch — are templates for the two common shapes. - If you have a torch/torchaudio mismatch, set
torchFamilyVersionand thehash, and feedaudioPackageOverlayinto your app builder's Python-package extensions. Otherwise delete that block. - Adjust the
--replace-failanchor string to match your wrapper's exactexport PYTHONPATH='quoting.
Caveats¶
sitecustomizeis process-global: it affects every interpreter run through that wrapper. Scope the shim to the app's wrapper only (viapostFixupon that package), not a shared Python.- If the app sets
PYTHONNOUSERSITEor runs with-S,sitecustomizeauto-import can be disabled — check the wrapper. ThePYTHONPATHprepend still makes the module available, but you may need toimportit explicitly. - Monkeypatches against a moving upstream are inherently transient. Keep them
small, keep the
try/exceptguards, and delete them once you upgrade past the skew.
Source¶
overlays/nix-python-pythonpath-sitecustomize-shim/default.nix
# nix-python-pythonpath-sitecustomize-shim
#
# Patch a Nix-built Python application's runtime WITHOUT patching its source,
# by injecting a `sitecustomize.py` onto the wrapper's PYTHONPATH. CPython
# imports `sitecustomize` automatically at interpreter startup (before your app's
# `main`), so any module you drop earliest on PYTHONPATH gets a free "run this
# first" hook. This is the perfect seam for defensive monkeypatches that paper
# over upstream version drift (e.g. a library got bumped in your nixpkgs but the
# app pins an older API shape).
#
# It also shows the sibling trick: overriding a torch-family Python package's
# `src` + `version` in an overlay so it builds against the torch that is actually
# installed in your package set, instead of the stale version the package pins.
#
# This file is a self-contained example overlay. Replace the placeholders marked
# `# EDIT:` with your own package and hashes. Everything else is the reusable
# mechanism.
{
# The package to patch. Pass your own derivation in; the default is a tiny
# placeholder so the file evaluates/parses standalone.
#
# In real use this is typically a Python app built via buildPythonApplication
# or a flake's `mkApp`, exposing a `$out/bin/<app>` wrapper script that sets
# export PYTHONPATH='...'
# (the pythonRelaxDeps / makeWrapper style). We rewrite that one line.
appName ? "myapp",
}:
final: prev:
let
# -------------------------------------------------------------------------
# 1. The sitecustomize shim.
#
# `writeTextDir "sitecustomize.py" <body>` produces a store path that is a
# DIRECTORY containing exactly `sitecustomize.py`. Prepending that directory
# to PYTHONPATH makes CPython auto-import it at startup. Keep every patch in
# its own bare try/except so a shim failure can NEVER stop the app from
# booting — a broken monkeypatch that crashes the interpreter is far worse
# than the bug it was trying to fix.
#
# The body below is a generic template. Swap `some_library` /
# `app.internal.module` / the patched function for whatever your version skew
# actually requires. The VALUE of this recipe is the injection seam, not this
# particular patch.
compatShim = final.writeTextDir "sitecustomize.py" ''
# Auto-imported by CPython at interpreter startup (it is on PYTHONPATH).
# Each patch is isolated so a failure can never break application boot.
# --- Example A: reconcile a changed function signature -------------------
# An upstream helper changed from "decorator factory" to "bare decorator"
# (or vice versa) between the version the app pins and the one installed.
# Wrap it to accept both call conventions.
try:
import some_library.util as _util
_real = _util.some_helper
def _compat(*args, **kwargs):
if args and callable(args[0]) and not kwargs:
return _real(args[0]) # bare-decorator form
def _decorate(fn): # decorator-factory form
return fn
return _decorate
_util.some_helper = _compat
except Exception:
pass
# --- Example B: patch a function inside a module that is not imported yet -
# The module you need to patch (`app.internal.module`) may only be imported
# lazily, long after sitecustomize runs. Install an `__import__` wrapper so
# the patch is (re)attempted every time ANY module is imported, and applies
# the instant your target module appears in sys.modules. Guard with a flag
# so it runs once and never recurses.
try:
import builtins as _bi, sys as _sys
_patching = [False]
def _apply_patch():
if _patching[0]:
return
_m = _sys.modules.get("app.internal.module")
if _m is None or getattr(_m, "_shim_patched", False):
return
if not hasattr(_m, "target_function"):
return
_patching[0] = True
try:
_orig = _m.target_function
def _patched(*args, **kwargs):
# ... your corrected behavior here; call _orig if useful ...
return _orig(*args, **kwargs)
_m.target_function = _patched
_m._shim_patched = True
finally:
_patching[0] = False
_real_import = _bi.__import__
def _wrapped_import(name, globals=None, locals=None, fromlist=(), level=0):
mod = _real_import(name, globals, locals, fromlist, level)
_apply_patch()
return mod
_bi.__import__ = _wrapped_import
_apply_patch() # in case the target is already imported
except Exception:
pass
'';
# -------------------------------------------------------------------------
# 2. (Optional) build a torch-family package against the INSTALLED torch.
#
# A package like torchaudio pins a specific torch version. If your nixpkgs
# ships a different torch, building torchaudio's pinned source against the
# installed torch's buildInputs fails (missing CUDA headers / ABI mismatch,
# e.g. `cusparse.h` not found). Fix by overriding torchaudio's src+version to
# MATCH the torch you actually have, while leaving `torch` itself untouched so
# triton/torch store paths don't fork.
#
# This is expressed as an extra Python-package overlay you can feed to a
# package builder, or apply directly to `pythonPackagesExtensions`.
torchFamilyVersion = "2.11.0"; # EDIT: match your installed torch version.
audioPackageOverlay = _: pyPrev: {
torchaudio = pyPrev.torchaudio.overridePythonAttrs (_: {
version = torchFamilyVersion;
src = final.fetchFromGitHub {
owner = "pytorch";
repo = "audio";
tag = "v${torchFamilyVersion}";
# EDIT: hash for the tag above. Get it with:
# nix-prefetch-github pytorch audio --rev v2.11.0
hash = "sha256-0000000000000000000000000000000000000000000=";
};
});
};
# Placeholder base package so this overlay parses and evaluates standalone.
# In real use, REPLACE this with your actual app derivation — e.g. one built
# from a flake input's builder, passing `audioPackageOverlay` into that
# builder's Python-package extensions so the torch fix lands in the closure.
basePackage =
prev.${appName} or (
final.runCommand appName { } ''
mkdir -p $out/bin
cat > $out/bin/${appName} <<'EOF'
#!${final.runtimeShell}
export PYTHONPATH='/dummy/site-packages'
exec ${final.python3}/bin/python -c "import sys" "$@"
EOF
chmod +x $out/bin/${appName}
''
);
in
{
# -------------------------------------------------------------------------
# 3. Wire the shim onto the wrapper's PYTHONPATH via postFixup.
#
# The app's wrapper script contains a line like:
# export PYTHONPATH='/nix/store/...-site-packages:...'
# We PREPEND the shim directory to it. `--replace-fail` (not `--replace`)
# is deliberate: if the wrapper's format ever changes and the anchor string
# is gone, the build FAILS LOUDLY instead of silently shipping without the
# shim. Adjust the anchor string to match your wrapper's exact quoting.
#
# Order matters: the shim dir must come FIRST so `sitecustomize.py` resolves
# to ours (and so any modules the shim ships shadow the app's).
${appName} = basePackage.overrideAttrs (old: {
postFixup = (old.postFixup or "") + ''
substituteInPlace $out/bin/${appName} \
--replace-fail "export PYTHONPATH='" "export PYTHONPATH='${compatShim}:"
'';
});
}