Du packst ein Python-Projekt in eine Nix-Flake – und prompt merkst du: Das Paket, das du brauchst, existiert in nixpkgs gar nicht. Oder es ist fünf Versionen zu alt. Die klassische Reaktion: venv zurück, pip install, oder ein Workaround-Skript in die flake.nix. Muss aber nicht sein. Mit den richtigen drei Nix-Werkzeugen (buildPythonPackage, overridePythonAttrs, withPackages) holst du dir fehlende oder alte Pakete in deine Flake – reproducible, wartbar, ohne Detours.

1. Ausgangslage

Vorher: ein projektfremdes shell.nix

Die Entwicklungsumgebung wurde ursprünglich mit einem env/-virtualenv (./init.sh, pip install) betrieben, daneben existierte ein shell.nix, das aus einem anderen Flask-Projekt stammte. Es passte inhaltlich nicht zu diesem Projekt und pinnte nixpkgs über ein fetchTarball mit manuellem sha256:

unstableTarball = fetchTarball {
  url    = "https://github.com/NixOS/nixpkgs/archive/3e41b24…tar.gz";
  sha256 = "1rb3aw213s8ms3nxj9b1dya90zh1drscjq7aly4v85farywvw4xg";
};

Das ist zwar reproduzierbar, aber mühsam zu aktualisieren: Bei jedem nixpkgs-Update muss man von Hand die URL ändern und den Hash neu prefetchen. Es gibt keine maschinell gepflegte Lock-Datei.

Das eigentliche Problem: pytest-bdd

Die Test-Suite nutzt pytest-bdd (BDD-Szenarien aus features/*.feature). Zwei harte Randbedingungen kollidierten:

  1. nixpkgs liefert nur pytest-bdd 7.1.2 – auch im nixpkgs-unstable-HEAD.
  2. Die Suite verlässt sich auf das Pfad-Auflösungsverhalten von 8.x: bdd_features_base_dir wird in 8.x relativ zur rootdir aufgelöst, in 7.x anders. Mit 7.1.2 findet pytest die Szenarien nicht → NoScenariosFound, 12 Collection-Errors, nur 94 statt 156 Tests.

Das alte venv hatte schlicht pytest-bdd 8.1.0 per pip von PyPI gezogen – ein Komfort, den nixpkgs hier nicht bietet.

  1. Erschwerend: pytest-bdd 8 braucht zur Laufzeit das Paket gherkin-official, das in nixpkgs überhaupt nicht existiert.

Die Aufgabe war also nicht nur „venv → Flake", sondern konkret: Wie bekomme ich in einer Flake ein Python-Paket in einer neueren Version als nixpkgs es kennt – inklusive einer Dependency, die nixpkgs gar nicht hat?


2. Die drei Kernkonzepte der Lösung

Bevor wir Zeile für Zeile durch flake.nix gehen, hier die drei Werkzeuge, die zusammen das Problem lösen. Wer diese drei versteht, versteht das ganze File.

2.1 python313.withPackages – die Umgebung als Closure

pkgs.python313.withPackages (ps: [ ps.pytest ps.requests  ])

withPackages erzeugt einen Python-Interpreter, in dessen site-packages genau die angegebenen Pakete (plus deren transitive Abhängigkeiten) liegen. Das Ergebnis ist ein einzelnes Derivation-Verzeichnis mit einem bin/python, das diese Pakete „sieht". Man muss also nie einzelne Dependencies wie urllib3 (von requests) von Hand auflisten – Nix löst den Graphen auf.

Das ist das Nix-Pendant zu „virtualenv mit installierten Paketen", nur deklarativ und reproduzierbar.

2.2 overridePythonAttrs – ein bestehendes Rezept umbiegen

nixpkgs hat ein Rezept für pytest-bdd (nur eben Version 7.1.2). Statt es von Null neu zu schreiben, erben wir davon und ändern nur das Nötige: Version, Quelle (src), zusätzliche Dependencies. Das ist DRY und robust – wir profitieren weiter von allem, was das nixpkgs-Rezept sonst richtig macht (Build-Inputs, Patches, Metadaten).

ps.pytest-bdd.overridePythonAttrs (old: { version = "8.1.0"; src = ; })

old ist das ursprüngliche Attribut-Set; wir geben ein verändertes zurück.

2.3 buildPythonPackage – ein fehlendes Paket selbst bauen

Für gherkin-official gibt es kein Rezept in nixpkgs. Also schreiben wir selbst eines. Da es ein reines Python-Wheel ohne eigene Dependencies ist, ist das minimal: Quelle von PyPI holen, als Wheel installieren, fertig.

ps.buildPythonPackage { pname = ; version = ; format = "wheel"; src = ; }

Diese drei Bausteine greifen ineinander: Das selbstgebaute gherkin-official (2.3) wird als Dependency in den pytest-bdd-Override (2.2) gehängt, und der landet zusammen mit den übrigen Paketen in der withPackages-Umgebung (2.1).


3. flake.nix Zeile für Zeile

Jetzt das vollständige File, in Blöcken kommentiert.

3.1 Header und description

# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2026, Andreas Brinner
# Alle Rechte vorbehalten.
{
  description =
    "ki_managed - NixOS-Server-Management (DNS, Mail, Reverse-Proxy)";

Der Lizenz-Header ist Projektpflicht (siehe AGENTS.md). description ist reine Metadata, die nix flake show anzeigt. Das äußere { … } ist das Flake-Attribut-Set: Eine Flake ist im Kern ein Attribut-Set mit den reservierten Schlüsseln inputs und outputs.

3.2 inputs – woher kommt nixpkgs

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
  };

Wir deklarieren eine Abhängigkeit: nixpkgs, und zwar den nixpkgs-unstable-Branch. Wichtig ist die Arbeitsteilung mit flake.lock:

  • Hier (flake.nix) steht nur der bewegliche Branch-Name.
  • Die flake.lock pinnt daraus einen konkreten Commit + narHash.

Dadurch ist der Build reproduzierbar (jeder bekommt exakt denselben nixpkgs-Stand), aber bequem aktualisierbar: Ein nix flake update schreibt einen neuen Commit in die Lock-Datei – ohne dass man URLs oder Hashes von Hand anfasst. Genau das löst die Schwäche des alten fetchTarball-Pins (CHG-009) ab.

Warum unstable und nicht ein Release wie nixos-24.11? Weil wir einen möglichst neuen Stand brauchen (u. a. damit das pytest-bdd-Rezept, auf dem wir aufsetzen, und dessen Build-Inputs aktuell sind).

3.3 outputs und die Multi-System-Helfer

  outputs = { self, nixpkgs }:
    let
      supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
      forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
      pkgsFor = system: import nixpkgs { inherit system; };

outputs ist eine Funktion. Sie bekommt die aufgelösten inputs herein (hier destrukturiert als self und nixpkgs) und gibt das Attribut-Set zurück, das die Flake nach außen anbietet (devShells, …).

  • supportedSystems: Wir unterstützen Intel/AMD- und ARM-Linux (z. B. Apple-Silicon unter Linux/VM).
  • forAllSystems: genAttrs ist ein Helfer, der aus der Liste ein Attribut-Set baut: forAllSystems (system: X) ergibt { x86_64-linux = X; aarch64-linux = X; }. So müssen wir die Outputs nicht zweimal hinschreiben. Das ist das Standard-Idiom gegen das Problem, dass Flake-Outputs pro System angegeben werden müssen.
  • pkgsFor: importiert das nixpkgs-Paketset für ein konkretes System. import nixpkgs { … } evaluiert nixpkgs; inherit system; ist Kurzform für system = system;.

3.4 pythonEnvFor – die Python-Umgebung als Funktion

      pythonEnvFor = pkgs:
        let
          ps = pkgs.python313Packages;

Wir kapseln die gesamte Python-Umgebung in eine Funktion über pkgs (damit sie pro System neu gebaut werden kann). ps ist nur eine Abkürzung für das Python-3.13-Paketset – das spart im Folgenden viel Tipparbeit.

3.4.1 Das fehlende Paket: gherkin-official
          gherkin-official = ps.buildPythonPackage rec {
            pname = "gherkin-official";
            version = "29.0.0";
            format = "wheel";
            src = pkgs.fetchPypi {
              inherit version format;
              pname = "gherkin_official";
              dist = "py3";
              python = "py3";
              hash = "sha256-JpZ7DVN6MCEZBmdCZp4Oi2Y+YydpMwvmdUV66ZPh0bw=";
            };
            pythonImportsCheck = [ "gherkin" ];
          };

Hier bauen wir das in nixpkgs fehlende Paket selbst:

  • rec macht das Set rekursiv – src kann auf version und format desselben Sets zugreifen (über inherit version format;).
  • format = "wheel"; sagt: Es ist ein vorgebautes Wheel (.whl), kein sdist, das noch kompiliert werden müsste. Für ein reines Python-Paket ohne C-Extensions ist das der schnellste, einfachste Weg.
  • fetchPypi lädt direkt von PyPI. Die Tücken im Detail:
  • pname = "gherkin_official" (mit Unterstrich!): Der Wheel-Dateiname auf PyPI nutzt Unterstriche, während der „offizielle" Paketname (oben) den Bindestrich trägt. fetchPypi baut den Download-Pfad aus pname, deshalb muss hier die Unterstrich-Variante stehen.
  • dist = "py3" und python = "py3": Teil des Wheel-Dateinamens (…-py3-none-any.whl). Damit zielt der Fetch auf das universelle py3-Wheel.
  • hash: Der Integritäts-Hash der Quelle. Bekommt man via nix-prefetch-url oder schlicht aus der Fehlermeldung beim ersten Build (Nix nennt den erwarteten Hash). Genau dafür liegt nix-prefetch in der Dev-Shell (siehe 3.6).
  • pythonImportsCheck = [ "gherkin" ]: Ein Smoke-Test nach dem Build – Nix versucht import gherkin. Beachte: Der Importname (gherkin) unterscheidet sich vom Paketnamen (gherkin-official). Schlägt der Import fehl, bricht der Build ab. Billige Versicherung, dass das Paket überhaupt ladbar ist.
3.4.2 Das zu alte Paket: pytest-bdd 7.1.2 → 8.1.0
          pytest-bdd-8 = ps.pytest-bdd.overridePythonAttrs (old: {
            version = "8.1.0";
            src = pkgs.fetchFromGitHub {
              owner = "pytest-dev";
              repo = "pytest-bdd";
              tag = "8.1.0";
              hash = "sha256-jxrjUXmyDEfw1sxwnlSUAfz3Kkv/4TwKFx7cone0Eyw=";
            };
            dependencies = (old.dependencies or [ ]) ++ [
              ps.packaging
              gherkin-official
            ];
            doCheck = false;
            nativeCheckInputs = [ ];
          });

Das ist das Herzstück. Wir nehmen das vorhandene pytest-bdd-Rezept und biegen vier Dinge um:

  • version: kosmetisch/Metadaten – die eigentliche Quelle bestimmt src.
  • src via fetchFromGitHub: Wir holen den getaggten Release 8.1.0 direkt aus dem Upstream-Repo. (Alternative wäre fetchPypi mit sdist; aus GitHub per tag zu bauen ist hier gut nachvollziehbar.) Der hash sichert wieder die Integrität.
  • dependencies: Hier wird der Knoten geschlossen. pytest-bdd 8 braucht zur Laufzeit zusätzlich packaging und unser selbstgebautes gherkin-official. (old.dependencies or [ ]) nimmt die bestehenden Laufzeit-Deps des 7.x-Rezepts und ergänzt sie via ++. Das or [ ] ist defensiv, falls das Attribut fehlt. Genau hier wird das fehlende Paket aus 3.4.1 eingehängt.
  • doCheck = false + nativeCheckInputs = [ ]: Wir schalten die internen Selbsttests von pytest-bdd ab. Warum? Das nixpkgs-Rezept führt beim Bauen pytest-bdds eigene Test-Suite aus – über einen pytest7CheckHook, der ein pytest 7 erwartet. Unsere Umgebung bringt aber pytest 9 mit. Diese Selbsttests sind für unsere Dev-Shell völlig irrelevant (wir benutzen pytest-bdd, wir entwickeln es nicht), würden aber den Build sprengen. Also deaktivieren. nativeCheckInputs = [ ] räumt die jetzt überflüssigen Test-Hilfs-Inputs ab.

Diese vier Zeilen sind die Antwort auf „neuere Version als nixpkgs": erben, Quelle tauschen, Deps ergänzen, fremde Selbsttests deaktivieren.

3.4.3 Alles zusammenführen mit withPackages
        in
        pkgs.python313.withPackages (_: [
          ps.pytest
          pytest-bdd-8
          ps.requests
          ps.dnspython
          ps.black
          ps.flake8
        ]);

Der let-Block endet (in), und wir bauen die finale Umgebung. Die Paketliste ist bewusst schlank – nur was wirklich importiert wird, plus die DoD-Tooling-Checks:

  • ps.pytest + pytest-bdd-8: Test-Framework + unser gepatchtes BDD.
  • ps.requests: Laufzeit für scripts/umami_domains.py und tests/test_http_reachability.py.
  • ps.dnspython: Laufzeit für tests/test_dns.py (import dns.*).
  • ps.black, ps.flake8: Qualitäts-Gates aus AGENTS.md (Stack Python).

Das Funktionsargument ist (_: …) – der Unterstrich heißt „ich ignoriere das übergebene Paketset bewusst", weil wir unsere Pakete über das weiter oben gebundene ps und die lokalen Variablen (pytest-bdd-8) referenzieren. Das ist Absicht: So ist garantiert, dass pytest-bdd-8 unser Override ist und nicht versehentlich das 7.1.2 aus dem hineingereichten Set.

Wichtiger Effekt: Weil withPackages transitive Deps auflöst, taucht gherkin-official nicht in dieser Liste auf – es kommt automatisch über die dependencies von pytest-bdd-8 mit herein.

3.5 devShells.default – die eigentliche Shell

    in
    {
      devShells = forAllSystems (system:
        let
          pkgs = pkgsFor system;
          pythonEnv = pythonEnvFor pkgs;
        in
        {
          default = pkgs.mkShell {
            packages = [
              pythonEnv
              pkgs.opencode
              pkgs.sops
              pkgs.nix-prefetch
            ];

Jetzt wird geliefert. forAllSystems sorgt dafür, dass es devShells.x86_64-linux.default und devShells.aarch64-linux.default gibt. Pro System bauen wir pkgs und daraus pythonEnv.

mkShell definiert die Umgebung, die nix develop betritt. In packages:

  • pythonEnv: unsere komplette Python-Welt (3.4).
  • pkgs.opencode: der KI-Coding-Agent.
  • pkgs.sops: zum Entschlüsseln der secrets/ (sops-nix).
  • pkgs.nix-prefetch: praktisch, um beim Aktualisieren neue Hashes zu ermitteln (siehe Ausblick).

3.6 shellHook – Begrüßung und PYTHONPATH

            shellHook = ''
              export PYTHONPATH="$PWD/scripts:$PYTHONPATH"

              echo "ki_managed dev-shell aktiv"
              echo "  Python:   $(python --version)"
              echo "  pytest:   $(pytest --version | head -n1)"
              echo "  opencode: $(opencode --version 2>/dev/null || \
                echo 'verfuegbar')"
              echo ""
              echo "Checks:  pytest  |  flake8 scripts/ tests/  |  \
                black --check scripts/ tests/"
            '';

Der shellHook ist Bash, das beim Betreten ausgeführt wird.

  • export PYTHONPATH="$PWD/scripts:…": Der entscheidende funktionale Teil. Mehrere Tests importieren Module direkt aus scripts/ (z. B. import dump_dkim_keys). Da scripts/ kein installiertes Paket ist, machen wir es über PYTHONPATH importierbar. (pytest.ini setzt testpaths=tests; die Tests selbst liegen also woanders als der zu importierende Code.)
  • Die echo-Zeilen sind reine Ergonomie: Sie zeigen beim Start die Versionen und die drei DoD-Check-Kommandos. Praktisch zur Selbstkontrolle, ob man wirklich in der Flake-Umgebung (Python 3.13.13, pytest 9) steckt und nicht versehentlich im alten venv.

Damit ist das File komplett: Header → ein Input → Multi-System-Helfer → Python-Umgebung (fehlendes Paket bauen, altes Paket überschreiben, alles via withPackages bündeln) → mkShell mit Tooling und PYTHONPATH.


4. Bonus: Warum der Umstieg einen versteckten Test-Bug aufdeckte

Der Wechsel brachte nebenbei einen neueren Python-Interpreter (3.13.13 in der Flake statt 3.13.11 im alten venv). Prompt schlugen 4 Tests in test_mailserver_cert_delivery.py fehl – aber nicht wegen Nix, sondern wegen eines latenten Bugs im Testcode.

Die Test-Hilfsfunktion las eine Zertifikatsdatei von einer VM und gab die komplette Kette (Leaf + 3 Intermediates) an ssl.PEM_cert_to_DER_cert. Diese Funktion akzeptiert aber genau ein Zertifikat:

  • Python 3.13.11 war nachsichtig und nahm einfach das erste.
  • Python 3.13.13 validiert strenger und wirft binascii.Error.

Der Fix (tests/conftest.py) extrahiert vor der Konvertierung nur den ersten PEM-Block (das Leaf-Zertifikat):

def _first_pem_certificate(pem_text):
    begin = "-----BEGIN CERTIFICATE-----"
    end = "-----END CERTIFICATE-----"
    start = pem_text.find(begin)
    end_pos = pem_text.find(end, start)
    assert start != -1 and end_pos != -1, "Keine PEM-Zertifikatsdaten gefunden"
    stop = end_pos + len(end)
    return pem_text[start:stop] + "\n"

Zwei Lehren, die über dieses Projekt hinaus gelten:

  1. Eine neuere, gepinnte Toolchain ist ein kostenloser Schärfetest. Nix macht den Interpreter-Wechsel explizit und reproduzierbar – dadurch fällt latent fehlerhafter Code auf, statt unbemerkt „zufällig" zu funktionieren.
  2. Der Slice-Ausdruck wurde absichtlich in die Variable stop ausgelagert. Inline (pem_text[start : end_pos + len(end)]) formatiert black mit Leerzeichen um den Doppelpunkt, was flake8 (E203) wiederum anmeckert. Da das Projekt bewusst keine .flake8-Config hat, wird der Konflikt durch Umschreiben statt durch ein noqa/Config-Workaround gelöst.

5. Ausblick: Generalisierbarkeit und Versionsupgrades

Ist dieses Muster allgemein nutzbar?

Ja – die drei Bausteine aus Abschnitt 2 sind das Standard-Repertoire für Python in Nix und unabhängig von diesem Projekt anwendbar:

  • Paket fehlt in nixpkgsbuildPythonPackage (für reine Python-Pakete oft als format = "wheel" ein Dreizeiler; bei C-Extensions kommen nativeBuildInputs/buildInputs dazu).
  • Paket zu altoverridePythonAttrs und nur version/src/ dependencies umbiegen, statt ein Rezept zu duplizieren.
  • Mehrere Pakete bündelnwithPackages, das transitive Deps löst.

Diese Lösung ist allerdings bewusst schlank statt vollautomatisch. Wer eine echte, gelockte Dependency-Liste pflegen will (mit exakten Versionen für alle transitiven Pakete), greift eher zu Werkzeugen wie uv2nix, poetry2nix oder pip2nix, die aus uv.lock/poetry.lock ein komplettes Nix-Set generieren. Hier wäre das Overkill: Die pyproject.toml enthält nur [tool.black], es gibt eine Handvoll Abhängigkeiten, und für die genügt das manuelle, gut kommentierte Set.

Heuristik: Wenige, stabile Dependencies + ein bis zwei Sonderfälle → manuelles withPackages wie hier. Große, häufig wechselnde Dependency-Bäume mit striktem Lockfile-Anspruch → Lock-getriebener Generator (uv2nix & Co.).

Was ist bei Versionsupgrades zu tun? (konzeptionell)

Drei Update-Achsen, die unabhängig voneinander wandern:

  1. nixpkgs aktualisieren. nix flake update zieht den neuesten Commit des nixpkgs-unstable-Branch und schreibt ihn in flake.lock. Das ist der normale „alles etwas neuer"-Schritt. Reproduzierbarkeit bleibt gewahrt, da die Lock-Datei den exakten Stand festhält. Anschließend einmal die Suite laufen lassen – ein neuer Interpreter kann (siehe Abschnitt 4) latente Bugs sichtbar machen.

  2. pytest-bdd selbst hochziehen. Wenn eine neue pytest-bdd-Version nötig wird: im Override version/tag anpassen und den hash neu bestimmen. Der praktische Trick: Hash erst auf einen offensichtlich falschen Wert setzen (oder pkgs.lib.fakeHash), bauen, und den von Nix gemeldeten erwarteten Hash übernehmen. Dafür liegt nix-prefetch in der Shell. Beim Versionssprung prüfen, ob sich die Laufzeit-Dependencies geändert haben (kommt etwas hinzu/weg?) und die dependencies-Liste angleichen.

  3. Der Override wird überflüssig. Sobald nixpkgs selbst pytest-bdd >= 8 ausliefert, kann der ganze overridePythonAttrs-Block entfallen – dann genügt wieder ps.pytest-bdd in der withPackages-Liste. Prüfpunkt nach jedem nix flake update: Welche Version liefert nixpkgs jetzt? Sobald sie ausreicht, ist der selbstgebaute Pfad reiner Ballast und sollte zurückgebaut werden (weniger Eigencode = weniger Wartung).

  4. gherkin-official bleibt vorerst Eigenbau. Es ist (noch) nicht in nixpkgs. Bei einem Update genügen version + hash. Sollte es eines Tages in nixpkgs landen und automatisch als pytest-bdd-Dependency mitkommen, entfällt auch diese Definition – dann zusammen mit Punkt 3.

Faustregel für die Wartung: Nach jedem nix flake update einmal pytest (volle Suite) plus flake8/black laufen lassen und kurz fragen, ob die Eigenbauten (pytest-bdd-Override, gherkin-Paket) inzwischen durch nixpkgs-Bordmittel ersetzbar sind. Jeder entfernte Eigenbau ist gewonnene Wartungsruhe.