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:
-
nixpkgs liefert nur
pytest-bdd 7.1.2– auch imnixpkgs-unstable-HEAD. - Die Suite verlässt sich auf das Pfad-Auflösungsverhalten von 8.x:
bdd_features_base_dirwird 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.
- Erschwerend:
pytest-bdd 8braucht zur Laufzeit das Paketgherkin-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.lockpinnt 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:genAttrsist 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ürsystem = 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:
-
recmacht das Set rekursiv –srckann aufversionundformatdesselben Sets zugreifen (überinherit 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. -
fetchPypilä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.fetchPypibaut den Download-Pfad auspname, deshalb muss hier die Unterstrich-Variante stehen. -
dist = "py3"undpython = "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 vianix-prefetch-urloder schlicht aus der Fehlermeldung beim ersten Build (Nix nennt den erwarteten Hash). Genau dafür liegtnix-prefetchin der Dev-Shell (siehe 3.6). -
pythonImportsCheck = [ "gherkin" ]: Ein Smoke-Test nach dem Build – Nix versuchtimport 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 bestimmtsrc. -
srcviafetchFromGitHub: Wir holen den getaggten Release8.1.0direkt aus dem Upstream-Repo. (Alternative wärefetchPypimit sdist; aus GitHub pertagzu bauen ist hier gut nachvollziehbar.) Derhashsichert wieder die Integrität. -
dependencies: Hier wird der Knoten geschlossen.pytest-bdd 8braucht zur Laufzeit zusätzlichpackagingund unser selbstgebautesgherkin-official.(old.dependencies or [ ])nimmt die bestehenden Laufzeit-Deps des 7.x-Rezepts und ergänzt sie via++. Dasor [ ]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 einenpytest7CheckHook, 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ürscripts/umami_domains.pyundtests/test_http_reachability.py. -
ps.dnspython: Laufzeit fürtests/test_dns.py(import dns.*). -
ps.black,ps.flake8: Qualitäts-Gates ausAGENTS.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
withPackagestransitive Deps auflöst, tauchtgherkin-officialnicht in dieser Liste auf – es kommt automatisch über diedependenciesvonpytest-bdd-8mit 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 dersecrets/(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 ausscripts/(z. B.import dump_dkim_keys). Dascripts/kein installiertes Paket ist, machen wir es überPYTHONPATHimportierbar. (pytest.inisetzttestpaths=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:
- 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.
- Der Slice-Ausdruck wurde absichtlich in die Variable
stopausgelagert. Inline (pem_text[start : end_pos + len(end)]) formatiertblackmit Leerzeichen um den Doppelpunkt, wasflake8(E203) wiederum anmeckert. Da das Projekt bewusst keine.flake8-Config hat, wird der Konflikt durch Umschreiben statt durch einnoqa/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 nixpkgs →
buildPythonPackage(für reine Python-Pakete oft alsformat = "wheel"ein Dreizeiler; bei C-Extensions kommennativeBuildInputs/buildInputsdazu). -
Paket zu alt →
overridePythonAttrsund nurversion/src/dependenciesumbiegen, statt ein Rezept zu duplizieren. -
Mehrere Pakete bündeln →
withPackages, 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:
-
nixpkgs aktualisieren.
nix flake updatezieht den neuesten Commit desnixpkgs-unstable-Branch und schreibt ihn inflake.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. -
pytest-bddselbst hochziehen. Wenn eine neue pytest-bdd-Version nötig wird: im Overrideversion/taganpassen und denhashneu bestimmen. Der praktische Trick: Hash erst auf einen offensichtlich falschen Wert setzen (oderpkgs.lib.fakeHash), bauen, und den von Nix gemeldeten erwarteten Hash übernehmen. Dafür liegtnix-prefetchin der Shell. Beim Versionssprung prüfen, ob sich die Laufzeit-Dependencies geändert haben (kommt etwas hinzu/weg?) und diedependencies-Liste angleichen. -
Der Override wird überflüssig. Sobald nixpkgs selbst
pytest-bdd >= 8ausliefert, kann der ganzeoverridePythonAttrs-Block entfallen – dann genügt wiederps.pytest-bddin derwithPackages-Liste. Prüfpunkt nach jedemnix 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). -
gherkin-officialbleibt vorerst Eigenbau. Es ist (noch) nicht in nixpkgs. Bei einem Update genügenversion+hash. Sollte es eines Tages in nixpkgs landen und automatisch alspytest-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.