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, sopassthru/version metadata lies. - Any downstream package with a constraint like
gepa>=0.1.0(orgepa[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:
Or expose it through a pythonPackagesExtensions overlay so it's available as
python3Packages.gepa:
# 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-runnix-prefetch-github gepa-ai gepa --rev vX.Y.Zfor the newsrc.hash, and re-check whether thepostPatchversion literal (0.0.27) still matches the new tag'spyproject.toml— update or remove it as needed. A stale--replace-failliteral 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; [ ];
};
}