package-dspy¶
Packages
Package the DSPy Python LLM framework
(programming — not prompting — language models) for Nix, working around three
things upstream does that break a naive buildPythonPackage.
The problem¶
DSPy is not on nixpkgs at the version you probably want, and building it from the GitHub tag hits three obstacles that each fail the build in a different, confusing way:
-
The version string in the tagged tree doesn't match the tag. The git tag is
3.1.3but the checked-indspy/__metadata__.pyandpyproject.tomlstill say3.1.2.buildPythonPackagereads its dist metadata frompyproject.toml, so the wheel comes out labelled3.1.2. Nothing errors — you just get a package that lies about its own version, and any downstreamdspy>=3.1.3constraint ordspy.__version__assertion fails later, far from the cause. -
Some dependencies are pinned with
==.asyncer==0.0.8andgepa[dspy]==0.0.26are exact pins. nixpkgs almost never has the exact point release upstream pinned, so the runtime dependency check rejects the perfectly-compatible version nixpkgs does have. -
Most of the test suite needs a live LLM and the network. Whole test directories drive real OpenAI/Anthropic calls, open outbound sockets, or fetch remote PDF/mime fixtures. A hermetic build sandbox has no network, so these can never pass and must be excluded — otherwise the build fails on tests that were never going to run.
The fix (and the key insight)¶
All of these live in default.nix — the three upstream workarounds, plus one
Nix-side trap the sandbox forces on you:
-
Rewrite the version strings in
postPatchwithsubstituteInPlace ... --replace-fail. The important detail is--replace-fail, not--replace: it makes the build fail loudly the day upstream fixes their strings, so the patch can never silently become a no-op that leaves you back at the mislabelled-wheel bug. Patch bothdspy/__metadata__.pyandpyproject.toml— they carry the version independently. -
Loosen the
==pins to>=inpyproject.toml, again viasubstituteInPlace --replace-fail. Only loosen the ones that actually drift against nixpkgs (asyncer,gepa); leave the rest alone. -
Disable the network/LLM tests in two tiers. Directories that are end-to-end LLM tests go in
disabledTestPaths(dropped wholesale); individual network cases that live inside otherwise-useful test files go indisabledTests(dropped by test name).pythonImportsCheck = [ "dspy" ]still gives you a real smoke test that the package imports. -
Redirect
DSPY_CACHEDIRwith an exported shell variable, notenv. DSPy chooses its on-disk cache directory at import time and falls back to$HOME/.dspy_cache, which is not writable in the sandbox — so both the test run andpythonImportsCheckneed it moved. The trap is how: attribute values underenvare passed to the builder verbatim. Nothing expands them — not the shell, and certainly not make, so a value like"$(TMPDIR)/dummy"is not "the sandbox tempdir", it is the literal 15-byte string$(TMPDIR)/dummy, and DSPy happily creates a directory named$(TMPDIR)next to the build cwd. Because$TMPDIRonly exists inside the builder, the redirect has to be anexportfrom a hook —preBuildhere, since all phases share one shell andpreBuildprecedes the check, installCheck and preDist phases where pytest and the imports check run.
Usage¶
Call it as a Python package, typically from an overlay:
final: prev: {
python3 = prev.python3.override {
packageOverrides = pfinal: pprev: {
dspy = pfinal.callPackage ./default.nix { };
};
};
}
Then python3.pkgs.dspy (and python3.withPackages (ps: [ ps.dspy ])) are
available. Optional feature sets are exposed under optional-dependencies
(anthropic, langchain, mcp, weaviate, …).
Caveats / bumping the version¶
- When you change
version, update thesrchash(a failing build will print the correctsha256-…), and re-check the two--replace-failversion strings — if upstream has since made their metadata consistent, those replacements will now fail (by design) and you simply remove them. - The
==→>=loosening list and the disabled-test list are both tied to a specific upstream release. On a bump, expect to add/remove a few names as upstream reshuffles pins and tests. - This packages a moving target from a GitHub tag, not a stable nixpkgs derivation — treat the pinned lists as maintenance surface, not fire-and-forget.
Source¶
packages/package-dspy/default.nix
# Package the DSPy Python LLM framework for nixpkgs.
#
# Import this as a python-package callPackage, e.g. in an overlay:
#
# final: prev: {
# python3 = prev.python3.override {
# packageOverrides = pfinal: pprev: {
# dspy = pfinal.callPackage ./default.nix { };
# };
# };
# }
#
# The things this file exists to demonstrate live in `postPatch`, the
# test-disabling blocks, and the DSPY_CACHEDIR export below — see README.md
# for the "why".
{
lib,
buildPythonPackage,
fetchFromGitHub,
setuptools,
wheel,
anyio,
asyncer,
backoff,
cachetools,
cloudpickle,
diskcache,
joblib,
json-repair,
litellm,
magicattr,
numpy,
openai,
optuna,
orjson,
pydantic,
regex,
requests,
rich,
tenacity,
tqdm,
ujson,
xxhash,
gepa,
anthropic,
build,
datamodel-code-generator,
pillow,
pre-commit,
pytest,
pytest-asyncio,
pytest-mock,
ruff,
langchain-core,
mcp,
datasets,
pandas,
weaviate-client,
pytestCheckHook,
}:
buildPythonPackage rec {
pname = "dspy";
version = "3.1.3";
pyproject = true;
src = fetchFromGitHub {
owner = "stanfordnlp";
repo = "dspy";
tag = version;
# Update this hash when you bump `version` (nix-prefetch-url --unpack, or
# let a failing build print the correct sha256-... for you).
hash = "sha256-Mfl5ac367QnFgSHXTItBAQ0ksHR1mEKIjyptAbt/Bvc=";
};
# TRAP 1 — upstream ships an inconsistent version string.
# The git tag is 3.1.3 but `dspy/__metadata__.py` and `pyproject.toml`
# still say 3.1.2 inside the tagged tree. `buildPythonPackage` derives its
# dist metadata from pyproject.toml, so without this patch the built wheel
# is silently mislabelled 3.1.2 and any downstream `>=3.1.3` constraint or
# import-time `dspy.__version__` check breaks. `--replace-fail` (not
# `--replace`) makes the build FAIL LOUDLY the day upstream fixes their
# strings, so this patch can never rot into a silent no-op.
#
# TRAP 2 — upstream pins some deps with `==`. nixpkgs carries slightly
# different point releases of `asyncer`/`gepa`, so an exact pin makes the
# runtime dependency check fail even though the newer version is compatible.
# Loosen `==` to `>=` for the ones that drift.
postPatch = ''
substituteInPlace dspy/__metadata__.py \
--replace-fail '__version__="3.1.2"' '__version__="${version}"'
substituteInPlace pyproject.toml \
--replace-fail 'version="3.1.2"' 'version="${version}"'
substituteInPlace pyproject.toml \
--replace-fail 'asyncer==0.0.8' 'asyncer>=0.0.8' \
--replace-fail 'gepa[dspy]==0.0.26' 'gepa[dspy]>=0.0.26'
'';
build-system = [
setuptools
wheel
];
dependencies = [
anyio
asyncer
backoff
cachetools
cloudpickle
diskcache
gepa
joblib
json-repair
litellm
magicattr
numpy
openai
optuna
orjson
pydantic
regex
requests
rich
tenacity
tqdm
ujson
xxhash
];
optional-dependencies = {
anthropic = [
anthropic
];
dev = [
build
datamodel-code-generator
litellm
pillow
pre-commit
pytest
pytest-asyncio
pytest-mock
ruff
];
langchain = [
langchain-core
];
mcp = [
mcp
];
test_extras = [
datasets
langchain-core
mcp
optuna
pandas
];
weaviate = [
weaviate-client
];
};
__darwinAllowLocalNetworking = true; # some tests spin up a local server
nativeCheckInputs = [
pytestCheckHook
pytest-asyncio
datamodel-code-generator
litellm
pillow
]
++ litellm.optional-dependencies.proxy;
# TRAP 4 — DSPy picks its on-disk cache directory at *import* time, falling
# back to $HOME/.dspy_cache; $HOME is not writable in the build sandbox, so
# both the pytest run and `pythonImportsCheck` need it pointed elsewhere.
#
# This cannot be an `env` attribute. `env` values are handed to the builder
# verbatim — no shell expansion, and `$(VAR)` is *make* syntax, which nothing
# in a Nix build expands. `env.DSPY_CACHEDIR = "$(TMPDIR)/dummy"` therefore
# sets the literal string `$(TMPDIR)/dummy`, and DSPy dutifully creates a
# directory *named* `$(TMPDIR)` relative to the build cwd — nowhere near
# $TMPDIR.
#
# $TMPDIR only exists inside the builder, so it has to be exported from a
# hook. All phases share one shell, and `preBuild` runs before the check,
# installCheck and preDist phases, so one export covers pytest and the
# imports check alike.
preBuild = ''
export DSPY_CACHEDIR="$TMPDIR/dspy-cache"
'';
pythonImportsCheck = [
"dspy"
];
# TRAP 3 — most of DSPy's test suite talks to a real LLM (OpenAI/Anthropic),
# opens outbound sockets, or hits network mime-type/PDF fixtures. None of
# that works in a hermetic, network-less build sandbox, so it must be
# disabled. Whole directories that are end-to-end LLM tests are dropped via
# `disabledTestPaths`; individual network/LLM cases that live inside
# otherwise-runnable files are dropped by name via `disabledTests`.
disabledTestPaths = [
"tests/predict/test_rlm.py"
"tests/adapters"
"tests/clients"
"tests/predict"
"tests/primitives"
"tests/teleprompt"
];
disabledTests = [
"test_pdf_url_support"
"test_different_mime_types"
"test_mime_type_from_response_headers"
"test_pdf_from_file"
"test_image_input_formats"
"test_predictor_save_load"
"test_chat_lms_can_be_queried"
"test_dspy_cache"
"test_text_lms_can_be_queried"
"test_lm_calls_support_callables"
"test_lm_calls_support_pydantic_models"
"test_responses_api"
"test_responses_api_tool_calls"
"test_streamify_yields_expected_response_chunks"
"test_streaming_response_yields_expected_response_chunks"
"test_dspy_context_with_dspy_parallel"
"test_dspy_context_with_async_task_group"
];
meta = {
description = "Framework for programming—not prompting—language models";
homepage = "https://github.com/stanfordnlp/dspy";
changelog = "https://github.com/stanfordnlp/dspy/releases/tag/${version}";
license = lib.licenses.mit;
};
}