Skip to content

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:

  1. The version string in the tagged tree doesn't match the tag. The git tag is 3.1.3 but the checked-in dspy/__metadata__.py and pyproject.toml still say 3.1.2. buildPythonPackage reads its dist metadata from pyproject.toml, so the wheel comes out labelled 3.1.2. Nothing errors — you just get a package that lies about its own version, and any downstream dspy>=3.1.3 constraint or dspy.__version__ assertion fails later, far from the cause.

  2. Some dependencies are pinned with ==. asyncer==0.0.8 and gepa[dspy]==0.0.26 are 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.

  3. 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 postPatch with substituteInPlace ... --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 both dspy/__metadata__.py and pyproject.toml — they carry the version independently.

  • Loosen the == pins to >= in pyproject.toml, again via substituteInPlace --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 in disabledTests (dropped by test name). pythonImportsCheck = [ "dspy" ] still gives you a real smoke test that the package imports.

  • Redirect DSPY_CACHEDIR with an exported shell variable, not env. 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 and pythonImportsCheck need it moved. The trap is how: attribute values under env are 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 $TMPDIR only exists inside the builder, the redirect has to be an export from a hook — preBuild here, since all phases share one shell and preBuild precedes 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 the src hash (a failing build will print the correct sha256-…), and re-check the two --replace-fail version 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;
  };
}