Python Packages with Pip and Setuptools

Updated

September 12, 2026

Python Packages with Pip and Setuptools

pip install on the server is a moving PyPI. The boring default is: python3.pkgs.buildPythonApplication (CLI) or buildPythonPackage (library), deps from python3.pkgs.* on nixpkgs 26.05 — not pip in the sandbox.

poetry2nix / uv2nix exist. Use them when a lockfile is the team contract and you have measured buildPythonApplication as too coarse. They are not the first recipe.

Mental model

Builder For
buildPythonApplication A CLI with project.scripts — wraps PYTHONPATH
buildPythonPackage A library other Python code imports
python3.withPackages (ps: [ ps.requests ]) A devShell interpreter, not a shipped package
pyproject.toml
    build-system = setuptools / hatchling / …
    dependencies = python3.pkgs.requests  (from nixpkgs, not PyPI at build time)
         │
         ▼
$out/bin/desk-reporter   (wrapped)

pyproject = true; on modern trees. format = "setuptools" on old setup.py only — do not set both. The host python does not matter.

Attr (26.05) Old gist name Role
build-system nativeBuildInputs (setuptools/hatchling) Builds the wheel
dependencies propagatedBuildInputs Runtime Python deps
nativeCheckInputs checkInputs pytest and friends
pythonImportsCheck (often omitted) import pkg after install

pytestCheckHook in nativeCheckInputs is the nixpkgs default test runner. pythonRelaxDeps when an upstream pin is tighter than nixpkgs. Tests that hit the network fail in the sandbox — they must use python3.pkgs.

Worked examples

Case 1: Application

Save as pyproject.toml:

# pyproject.toml
[project]
name = "desk-reporter"
version = "1.0.0"
dependencies = ["requests"]
[project.scripts]
desk-reporter = "reporter:main"
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"

Save as desk_reporter.nix:

# desk_reporter.nix
{ lib, python3 }:

python3.pkgs.buildPythonApplication {
  pname = "desk-reporter";
  version = "1.0.0";
  pyproject = true;
  src = lib.cleanSource ./.;
  build-system = [ python3.pkgs.setuptools ];
  dependencies = [ python3.pkgs.requests ];
  pythonImportsCheck = [ "reporter" ];
  meta = {
    description = "Desk metrics reporting CLI";
    license = lib.licenses.mit;
    mainProgram = "desk-reporter";
  };
}
# default.nix
{ pkgs ? import <nixpkgs> { } }:

pkgs.callPackage ./desk_reporter.nix { }
nix-build default.nix
./result/bin/desk-reporter
head -5 result/bin/desk-reporter

The wrapper’s PYTHONPATH includes requests. Drop requests from dependencies and you get ModuleNotFoundError — put it back. The pyproject.toml list is documentation; nixpkgs attrs are the pin.

Case 2: Library vs application

# desklib.nix
{ python3, lib }:

python3.pkgs.buildPythonPackage {
  pname = "desklib";
  version = "1.0.0";
  pyproject = true;
  src = lib.cleanSource ./.;
  build-system = [ python3.pkgs.setuptools ];
  dependencies = [ python3.pkgs.pydantic ];
}

Libraries do not always install a bin/. Applications do. Mixing them up gives a store path with no bin/desk-reporter.

Case 3: Tests

# tests fragment
{
  nativeCheckInputs = [
    python3.pkgs.pytestCheckHook
    python3.pkgs.requests-mock
  ];
  pythonImportsCheck = [ "reporter" ];
  disabledTests = [
    "test_talks_to_live_prometheus"
  ];
  doCheck = true;
}

pytestCheckHook is a check input, not dependencies, unless the app imports pytest at runtime (it should not). pythonImportsCheck catches a missing dependencies entry even when you skipped tests. doCheck = false is a last resort for a broken upstream suite — not a way to hide pip install in checkPhase.

pythonRelaxDeps = [ "requests" ]; when pyproject.toml says requests==2.31.0 and nixpkgs has 2.32. Prefer relaxing the pin over vendoring an old requests.

Case 4: Dev shell

# shell.nix
{ pkgs ? import <nixpkgs> { } }:

pkgs.mkShell {
  packages = [
    (pkgs.python3.withPackages (ps: [ ps.requests ps.pytest ]))
  ];
}
nix-shell --run "python -c 'import requests; print(requests.__version__)'"
which python

Must be a store path. Humans run pytest here. CI builds Case 1. Do not pip install --user inside the shell “just this once.”

Case 5: Missing from nixpkgs

If desk-internal-sdk is not on 26.05:

  1. buildPythonPackage it in this flake (callPackage ./sdk.nix).
  2. Put it in dependencies of the app.

uv2nix / poetry2nix generate many package derivations from a lockfile. Worth it for an 80-dep web app. Not for a 40-line reporter. Overlaying a single buildPythonPackage is the boring missing-dep path.

The trap

The trap is pip install -r requirements.txt in buildPhase. Sandbox: no network. Even with --offline, those wheels are not Nix inputs unless you FOD them.

The other trap is propagatedBuildInputs copied from a 2019 gist. On current buildPythonPackage, use dependencies / build-system / nativeCheckInputs as in 26.05 nixpkgs python docs.

A third: format = "pyproject" and pyproject = true — pick pyproject = true. A fourth: putting pytest in dependencies so the CLI’s runtime closure includes a test runner.

The boring rule

  • buildPythonApplication for CLIs; buildPythonPackage for libs.
  • pyproject = true; + build-system + dependencies from python3.pkgs. Not format + pyproject.
  • No pip in the derivation. No unpinned requirements.txt as the only lock.
  • Tests: nativeCheckInputs (pytestCheckHook), pythonImportsCheck, hermetic.
  • pythonRelaxDeps for tight upstream pins. uv2nix/poetry2nix optional; one in-tree buildPythonPackage for a missing lib.

Try this

  1. Case 1 with a real pyproject.toml; nix-build; head -5 result/bin/desk-reporter (wrapper).
  2. Drop requests from dependencies; run; read ModuleNotFoundError; put it back.
  3. nix path-info -Shr result and find requests.
  4. python3.withPackages in a shell; python -c 'import requests'; confirm which python is a store path.
  5. Drop pythonImportsCheck, misspell a dependency attr, watch the build succeed and the CLI fail. Put pythonImportsCheck back.