Skip to content

python-package-gepa

Packages

Packaging an upstream Python tool for Nixpkgs when its pyproject.toml version string has drifted out of sync with its git tag — and keeping the closure lean by pushing heavy optional dependencies into extras.

The concrete package here is GEPA (a prompt / system-component optimizer), but the two techniques generalize to almost any buildPythonPackage you write by hand.

The trap: tag says v0.1.0, pyproject says 0.0.27

buildPythonPackage reads the wheel version out of pyproject.toml, not from the tag you fetched. Plenty of upstreams cut a git tag (v0.1.0) without bumping the version= line committed in the repo, so the tree at v0.1.0 still declares 0.0.27.

Consequences if you don't fix it:

  • The built wheel is named 0.0.27, so passthru/version metadata lies.
  • Any downstream package with a constraint like gepa>=0.1.0 (or gepa[dspy]==0.1.0) fails to resolve against your build.

The fix is a one-line postPatch:

postPatch = ''
  substituteInPlace pyproject.toml \
    --replace-fail 'version="0.0.27"' 'version="${version}"'
'';

Use --replace-fail, not --replace. --replace silently does nothing if the literal isn't found — so the day upstream finally fixes their pyproject.toml, your patch becomes a no-op and you'd never notice you're now depending on stale patch logic. --replace-fail turns that same event into a hard build error that tells you to delete the workaround.

The second lesson: heavy deps belong in optional-dependencies

GEPA's core is small, but its useful workflows want a big stack: litellm for LLM calls, datasets for data handling, and experiment trackers mlflow / wandb. Putting those in dependencies would force every consumer — including ones that only import the core — to build and carry that entire closure (and inherit its frequent breakages).

Instead they go in optional-dependencies keyed by use case:

optional-dependencies = {
  full = [ litellm datasets mlflow wandb tqdm ];  # everything
  dspy = [ litellm datasets tqdm ];               # DSPy integration only
};

Downstreams then depend on gepa for the lean core, or pull the extras explicitly (e.g. a DSPy package listing gepa in dependencies and matching the gepa[dspy] set). This keeps the base package importable with a minimal closure and makes the heavy path opt-in.

Usage

Build it directly:

python3Packages.callPackage ./default.nix { }

Or expose it through a pythonPackagesExtensions overlay so it's available as python3Packages.gepa:

# overlays/python-modules/gepa.nix
_prev: self: _super: {
  gepa = self.callPackage ./gepa.nix { };
}
# in your overlay that assembles pythonPackagesExtensions
final: prev: {
  pythonPackagesExtensions = prev.pythonPackagesExtensions ++ [
    (import ./python-modules/gepa.nix)
  ];
}

Caveats

  • doCheck = false. Upstream's test suite reaches for live LLM providers, which doesn't work in the Nix build sandbox. pythonImportsCheck = [ "gepa" ] is the smoke test instead.
  • Refresh the hash on version bumps. When you bump version, re-run nix-prefetch-github gepa-ai gepa --rev vX.Y.Z for the new src.hash, and re-check whether the postPatch version literal (0.0.27) still matches the new tag's pyproject.toml — update or remove it as needed. A stale --replace-fail literal will fail the build loudly, which is exactly the signal you want.

Source

packages/python-package-gepa/default.nix
# Packaging an upstream Python tool (GEPA) whose pyproject.toml hardcodes a
# stale version string out of sync with its own git tag.
#
# Two reusable techniques live here:
#
#   1. postPatch + substituteInPlace --replace-fail
#      The repo is tagged `v0.1.0` but its committed pyproject.toml still says
#      `version="0.0.27"`. buildPythonPackage derives the wheel version from
#      pyproject, so the build would produce a `0.0.27` wheel even though we
#      fetched the `v0.1.0` tag — and any downstream `>=0.1.0` constraint would
#      then fail to resolve. Rewrite the string at build time. Use
#      `--replace-fail` (not plain `--replace`) so the build errors loudly the
#      day upstream fixes their pyproject and the literal disappears, instead of
#      silently no-op'ing and shipping a wrong version forever.
#
#   2. Heavy deps go in optional-dependencies, not dependencies.
#      The core library is lean. LLM plumbing (litellm), dataset handling
#      (datasets), and experiment trackers (mlflow, wandb) are only needed by
#      users who opt in. Keeping them out of `dependencies` means importing the
#      package doesn't drag a giant closure (and its own frequent breakages)
#      into every consumer. Downstreams that need them ask for `gepa[full]` or
#      `gepa[dspy]`.
#
# Drop this in a python-modules overlay:
#
#   _prev: self: _super: {
#     gepa = self.callPackage ./gepa.nix { };
#   }
#
# and add that overlay to `pythonPackagesExtensions`.

{
  lib,
  buildPythonPackage,
  fetchFromGitHub,
  setuptools,
  wheel,
  litellm,
  datasets,
  mlflow,
  wandb,
  tqdm,
}:

buildPythonPackage rec {
  pname = "gepa";
  version = "0.1.0";
  pyproject = true;

  src = fetchFromGitHub {
    owner = "gepa-ai";
    repo = "gepa";
    tag = "v${version}";
    # nix-prefetch-github gepa-ai gepa --rev v0.1.0
    hash = "sha256-W0wW7dV8jMgeem8HjBYxcaL1VA9zBwMbePqLSsQe8qQ=";
  };

  # Upstream's committed pyproject.toml lags its own git tag. Realign the
  # declared version with the tag we actually fetched. --replace-fail makes the
  # build fail (rather than silently pass) once upstream fixes this and the
  # literal string no longer exists to match.
  postPatch = ''
    substituteInPlace pyproject.toml \
      --replace-fail 'version="0.0.27"' 'version="${version}"'
  '';

  build-system = [
    setuptools
    wheel
  ];

  # Keep the core install lean; only pull the heavy LLM / tracking stack when a
  # consumer explicitly asks for it via an extra.
  optional-dependencies = {
    full = [
      litellm
      datasets
      mlflow
      wandb
      tqdm
    ];
    dspy = [
      litellm
      datasets
      tqdm
    ];
  };

  pythonImportsCheck = [
    "gepa"
  ];

  # Upstream test suite reaches for live LLM providers; keep it off in the
  # sandbox and rely on pythonImportsCheck for a smoke test.
  doCheck = false;

  meta = {
    description = "Framework for optimizing textual system components using LLM-based reflection and Pareto-efficient evolutionary search";
    homepage = "https://github.com/gepa-ai/gepa";
    license = lib.licenses.mit;
    maintainers = with lib.maintainers; [ ];
  };
}