Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Python

mkSecureArtifact detects a Python project by a pyproject.toml at the root of src. It resolves dependencies from a Poetry lock file or a uv lock file (see uv projects), builds the application, and hands the result to mkHardenedImage.

The marker

The primary marker is pyproject.toml. A poetry.lock or uv.lock alongside it is the strongest signal for a hermetic build and is recorded as the buildConfigFile.

Required and optional ecosystemArgs

  • lock (required) — a vendored relative path to the Poetry lock file, e.g. ./poetry.lock. It must be a path in your repository, not a read out of the fetched src. Reading a lock file out of a fetched derivation is import-from-derivation, which nix flake check --no-build cannot evaluate and the repo’s no-ifd-lockfile lint forbids.
  • python (optional) — the interpreter (default python312).
  • overrides (optional) — { "package-name" = derivation; } to replace a generated dependency entirely. Use it for packages with native (maturin / C) build steps, non-PyPI sources, or private sources.
  • exclude (optional) — a list of exact lock names to skip (Windows-only, wheel-only-with-no-linux-source, or intentionally omitted packages).
  • format (optional) — the Python build format (default "pyproject").

Worked example

Vendor the lock file into your repo first, then:

hb.mkSecureArtifact {
  name = "my-api";
  version = "2.0.1";
  src = ./.;
  ecosystemArgs = {
    lock = ./poetry.lock; # vendored, relative
    python = pkgs.python312;
    exclude = [ "pywin32" "pyroscope-io" ];
    overrides = {
      "some-rust-pkg" = mySomeRustPkgDrv;
    };
  };
  image.cmd = [ "/bin/my-api" ];
}

Under the hood, the emitter reads poetry.lock at eval time with builtins.fromTOML (Poetry locks carry per-package sha256 hashes, so no build-time conversion and no IFD), builds every dependency that nixpkgs does not already provide via buildPoetryDeps, and wires them in as propagatedBuildInputs. Packages already present in python.pkgs are reused, not rebuilt.

Generated dependency derivations skip their upstream test suites (doCheck = false) — those virtually always need network, live services, or dev-only deps not in the lock. If you need an import check on a specific package, supply it through overrides.

uv projects

When lock points at a uv.lock, the emitter builds the project with uv2nix instead of buildPoetryDeps. uv2nix reads uv.lock at eval time with builtins.fromTOML (no IFD), and build-system-pkgs supplies the build backends (setuptools, flit-core, hatchling, …) that uv.lock doesn’t record per package.

Unlike the Poetry path, every package comes from the lock at its locked version, including packages nixpkgs also carries, as the Go, Rust and Ruby emitters do. The result is a virtual environment with the project and its default dependencies installed (no optional extras), so the project’s console scripts end up in its bin/.

By default every package in uv.lock is built from its source distribution, and a dependency that only publishes wheels fails the evaluation. uv.lock doesn’t record build backends, so each source-built dependency needs its backend added through overrides (or tool.uv.extra-build-dependencies). The backends themselves come from build-system-pkgs, which installs uv-build (the uv_build backend uv init sets up) from a prebuilt PyPI wheel. It’s only used during the build and doesn’t end up in the image. Dependencies from git sources, and wheels without a hash, aren’t supported, since uv2nix fetches them without a hash check. Any such entry anywhere in uv.lock fails the evaluation, including ones in dev groups or for other platforms.

  • lock (required) — a vendored relative path to uv.lock, e.g. ./uv.lock, with a copy of the project’s pyproject.toml next to it (and of each workspace member’s, at the same relative path). uv2nix reads them at eval time; the project itself is built from src.
  • python (optional) — the interpreter (default python312). It must satisfy the project’s requires-python.
  • sourcePreference (optional) — "sdist" (default) or "wheel". "wheel" installs the locked, hash-pinned PyPI wheels where they exist, so dependencies don’t need their build backends added. AGENTS.md prefers building from source, so state the reason next to it in the project’s flake. Compiled wheels can bundle native libraries (e.g. OpenSSL in cryptography’s) that the SBOM’s closure walk doesn’t list.
  • overrides (optional) — { "package-name" = derivation; } to replace a package, or an overlay final: prev: { ... } to patch one, e.g. to point a package at a nixpkgs library.

format and exclude don’t apply to uv projects: the build format comes from each package’s metadata, and uv2nix evaluates each dependency’s environment markers, so platform-specific packages (e.g. Windows-only) are skipped on their own.

hb.mkSecureArtifact {
  name = "my-api";
  version = "2.0.1";
  src = ./.;
  ecosystemArgs = {
    lock = ./uv.lock; # vendored, relative
    python = pkgs.python313;
    overrides = final: prev: {
      some-pkg = prev.some-pkg.overrideAttrs (old: {
        buildInputs = (old.buildInputs or [ ]) ++ [ pkgs.zlib ];
      });
    };
  };
  image.cmd = [ "/bin/my-api" ];
}

Derivation-first alternative

If your build doesn’t fit the Poetry-lock shape, build the application yourself and image it directly:

let
  myApi = pkgs.python312.pkgs.buildPythonApplication {
    pname = "my-api";
    version = "2.0.1";
    src = ./.;
    format = "pyproject";
    propagatedBuildInputs = with pkgs.python312.pkgs; [ fastapi uvicorn ];
  };
in
hb.mkHardenedImage {
  name = "my-api";
  version = "2.0.1";
  package = myApi;
  cmd = [ "/bin/my-api" ];
}

SBOM

Python wheels/sdists in the closure are captured in the CycloneDX SBOM. See SBOMs for how ecosystem dependencies are folded in and how to override auto-detection when needed.