Langage Nix par la pratique
Mise à jour :
Dans ce guide, j’adopte une approche différente : plutôt que de lister la syntaxe de façon abstraite, nous allons construire ensemble un projet concret du début à la fin. À chaque étape, j’introduis un nouveau concept du langage Nix que nous appliquons immédiatement.
Nous allons créer un environnement de développement Python complet avec :
- Python 3.12 avec des bibliothèques spécifiques
- Des outils de développement (git, vim, ripgrep)
- Des variables d’environnement personnalisées
- Une configuration modulaire et réutilisable
À la fin de ce guide, vous aurez un shell.nix fonctionnel et vous
comprendrez chaque ligne.
Prérequis
- Nix installé sur votre système
- Un terminal Linux
Qu’est-ce qu’un fichier shell.nix ?
Un fichier shell.nix est une recette déclarative qui décrit un
environnement de développement. Quand vous lancez nix-shell dans un dossier
contenant ce fichier, Nix :
- Lit le fichier
shell.nix - Télécharge les paquets nécessaires (s’ils ne sont pas en cache)
- Crée un shell isolé avec exactement ces outils disponibles
- Vous place dans cet environnement temporaire
Pourquoi utiliser shell.nix ?
| Problème classique | Solution avec shell.nix |
|---|---|
| ”Ça marche sur ma machine” | Environnement identique pour tous |
| Conflits de versions entre projets | Chaque projet a son propre environnement |
| Installation manuelle d’outils | Un seul nix-shell suffit |
| Documentation des dépendances | Le fichier shell.nix est la documentation |
Différence avec les environnements virtuels Python
Contrairement à venv qui ne gère que Python, shell.nix peut inclure
n’importe quel outil : compilateurs, bases de données, outils CLI, etc.
Et contrairement à Docker, il n’y a pas de conteneur : les outils sont
directement accessibles dans votre terminal.
Étape 1 : Premier shell.nix minimal
Créons notre premier fichier dans un nouveau dossier :
mkdir mon-projet-python && cd mon-projet-pythonCréez un fichier shell.nix avec ce contenu :
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell { packages = [ pkgs.python312 ];}Testons immédiatement :
nix-shellpython3 --version # Python 3.12.xexit # Sortir du shellÇa fonctionne ! Mais qu’avons-nous écrit exactement ? Décortiquons chaque élément dans les sections suivantes.
Étape 2 : Comprendre les fonctions
Rappel de notre code
Regardons à nouveau notre shell.nix :
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell { packages = [ pkgs.python312 ];}La première ligne { pkgs ? import <nixpkgs> {} }: est mystérieuse. C’est
en fait une fonction. Pourquoi ? Parce que Nix a besoin de savoir où
trouver les paquets comme python312. On lui dit : “donne-moi pkgs et
je te construis un environnement”.
Les fonctions en Nix : la base
En Nix, une fonction s’écrit avec : qui sépare l’argument du corps :
argument: ce_que_la_fonction_retourneExemple concret - une fonction qui double un nombre :
x: x * 2xest l’argument (le paramètre d’entrée)x * 2est le corps (ce qui est calculé et retourné)
Pour appeler cette fonction, on lui passe une valeur :
nix replnix-repl> double = x: x * 2nix-repl> double 510nix-repl> double 100200Fonctions avec plusieurs arguments
Nix ne supporte qu’un argument par fonction, mais on peut les chaîner :
a: b: a + bCela signifie : “prends a, puis retourne une fonction qui prend b et
retourne a + b”.
nix-repl> add = a: b: a + bnix-repl> add 3 47Les arguments nommés (notre cas)
Notre shell.nix utilise une syntaxe avec accolades { } :
{ pkgs }:Cela signifie : “j’attends un dictionnaire (attrset) qui contient une
clé pkgs”. C’est plus lisible quand il y a plusieurs arguments :
{ nom, age, ville }: "Je m'appelle ${nom}, j'ai ${toString age} ans, j'habite à ${ville}"La valeur par défaut avec ?
Le ? définit une valeur utilisée si l’argument n’est pas fourni :
{ pkgs ? import <nixpkgs> {} }:Traduction : “Si on me donne pkgs, je l’utilise. Sinon, je charge moi-même
nixpkgs avec import <nixpkgs> {}.”
Pourquoi notre shell.nix est une fonction ?
Quand vous tapez nix-shell, voici ce qui se passe :
- Nix lit le fichier
shell.nix - Il détecte que c’est une fonction (à cause du
:) - Il l’appelle avec
{}(un dictionnaire vide) - Comme
pkgsn’est pas fourni, la valeur par défautimport <nixpkgs> {}est utilisée - La fonction retourne le résultat de
pkgs.mkShell { ... }
C’est pourquoi cette première ligne est standard dans presque tous les fichiers Nix. Elle dit simplement : “charge les paquets disponibles”.
Étape 3 : Comprendre import et nixpkgs
Qu’est-ce que import ?
import charge et évalue un fichier Nix. C’est comme un require ou include
dans d’autres langages :
# Si config.nix contient { port = 8080; }let config = import ./config.nix;in config.port # 8080Qu’est-ce que <nixpkgs> ?
<nixpkgs> est un chemin spécial qui pointe vers la collection de paquets
Nix installée sur votre système (via les channels). C’est un raccourci pour
accéder aux ~80 000 paquets disponibles.
import <nixpkgs> {}Cette expression :
- Charge le fichier
default.nixde nixpkgs - L’appelle avec
{}(options vides) - Retourne un attrset gigantesque contenant tous les paquets
C’est pourquoi ensuite, on peut écrire pkgs.python312, pkgs.git, etc.
Testons dans le REPL
Le REPL (Read-Eval-Print Loop) est un terminal interactif qui permet de tester des expressions Nix à la volée. Vous tapez une expression, Nix l’évalue et affiche le résultat. C’est idéal pour expérimenter :
nix replnix-repl> pkgs = import <nixpkgs> {}nix-repl> pkgs.python312«derivation /nix/store/...-python3-3.12...»nix-repl> pkgs.git«derivation /nix/store/...-git-...»Le texte «derivation ...» signifie que c’est un paquet Nix prêt à être
construit ou utilisé. Tapez :q pour quitter le REPL.
Étape 4 : Comprendre les attrsets
Structure fondamentale
Un attrset (attribute set) est un dictionnaire clé-valeur. C’est la structure de données la plus importante en Nix :
{ name = "Alice"; age = 30; active = true;}On accède aux valeurs avec un point :
nix replnix-repl> personne = { name = "Alice"; age = 30; active = true; }nix-repl> personne.name"Alice"nix-repl> personne.age30nix-repl> personne.activetrue:qNotre mkShell est un attrset
Regardons à nouveau notre code :
pkgs.mkShell { packages = [ pkgs.python312 ];}pkgs.mkShell est une fonction qui attend un attrset en argument. Nous lui
passons { packages = [ pkgs.python312 ]; }.
Étape 5 : Ajouter plus de paquets
Enrichissons notre environnement avec git, vim et ripgrep :
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell { packages = [ pkgs.python312 pkgs.git pkgs.vim pkgs.ripgrep ];}Rechargez l’environnement :
nix-shellgit --version # Disponible !vim --version # Disponible !rg --version # Disponible !Simplifier avec with
Répéter pkgs. devient fastidieux. Le mot-clé with importe tous les
attributs d’un attrset dans le scope :
{ pkgs ? import <nixpkgs> {} }:
pkgs.mkShell { packages = with pkgs; [ python312 git vim ripgrep ];}C’est équivalent, mais plus lisible. with pkgs; signifie “dans la suite,
cherche d’abord dans pkgs”.
Étape 6 : Variables locales avec let
Pourquoi des variables ?
Imaginons que nous voulions définir le nom du projet et sa version à un seul
endroit. Le bloc let ... in crée des variables locales :
{ pkgs ? import <nixpkgs> {} }:
let projectName = "mon-api"; pythonVersion = pkgs.python312;inpkgs.mkShell { name = projectName; packages = with pkgs; [ pythonVersion git vim ];}Syntaxe de let
let variable1 = valeur1; variable2 = valeur2;in expression_qui_utilise_les_variablesVariables qui dépendent d’autres variables
Les variables peuvent référencer celles définies avant :
let a = 1; b = 2; sum = a + b; # 3in sum * 2 # 6Étape 7 : Listes de paquets Python
Le problème
Nous voulons Python avec des bibliothèques comme requests, flask ou
pytest. Mais pkgs.python312 est Python “nu” sans bibliothèques.
La solution : withPackages
nixpkgs fournit une fonction python312.withPackages qui crée un Python
avec les bibliothèques demandées :
{ pkgs ? import <nixpkgs> {} }:
let # Python avec bibliothèques pythonEnv = pkgs.python312.withPackages (ps: [ ps.requests ps.flask ps.pytest ]);inpkgs.mkShell { packages = with pkgs; [ pythonEnv git vim ripgrep ];}Testons :
nix-shellpython3 -c "import requests; print(requests.__version__)"# 2.31.0 (ou version similaire)Comprendre ps: […]
ps: [ ps.requests ps.flask ] est une fonction anonyme (lambda) :
psest l’argument (les paquets Python disponibles)[ ps.requests ps.flask ]est le corps (la liste retournée)
C’est équivalent à écrire :
(ps: [ ps.requests ps.flask ps.pytest ])Cette fonction est appelée par withPackages qui lui passe tous les paquets
Python disponibles.
Étape 8 : Variables d’environnement
shellHook pour le démarrage
shellHook est un script shell exécuté à l’entrée dans nix-shell :
{ pkgs ? import <nixpkgs> {} }:
let pythonEnv = pkgs.python312.withPackages (ps: [ ps.requests ps.flask ps.pytest ]);inpkgs.mkShell { packages = with pkgs; [ pythonEnv git vim ripgrep ];
shellHook = "echo '🐍 Environnement Python activé !' && echo \"Python: $(python3 --version)\"";}Chaînes multilignes
En Nix, les chaînes multilignes utilisent deux apostrophes simples consécutives. Voici l’équivalent avec une string standard :
# String simple avec \n"Première ligne\nDeuxième ligne"
# En shellHook, combinez plusieurs echo :shellHook = "echo 'Ligne 1' && echo 'Ligne 2'";L’indentation minimale commune est automatiquement supprimée dans les chaînes multilignes Nix.
Variables d’environnement personnalisées
Ajoutez des variables avec les attributs de l’attrset :
{ pkgs ? import <nixpkgs> {} }:
let pythonEnv = pkgs.python312.withPackages (ps: [ ps.requests ps.flask ps.pytest ]);inpkgs.mkShell { packages = with pkgs; [ pythonEnv git vim ripgrep ];
# Variables d'environnement PROJECT_NAME = "mon-api"; FLASK_ENV = "development"; FLASK_DEBUG = "1";
shellHook = "echo '🐍 Projet:' $PROJECT_NAME && echo 'Flask: mode' $FLASK_ENV";}Testons :
nix-shellecho $PROJECT_NAME # mon-apiecho $FLASK_ENV # developmentÉtape 9 : Interpolation de chaînes
Syntaxe ${…}
L’interpolation permet d’insérer des expressions dans des chaînes :
let name = "Alice";in "Bonjour, ${name} !"# "Bonjour, Alice !"Conversion de types
Seules les chaînes peuvent être interpolées. Pour les nombres, utilisez
toString :
let port = 8080;in "Serveur sur le port ${toString port}"# "Serveur sur le port 8080"Application à notre projet
{ pkgs ? import <nixpkgs> {} }:
let projectName = "mon-api"; projectVersion = "1.0.0";
pythonEnv = pkgs.python312.withPackages (ps: [ ps.requests ps.flask ]);inpkgs.mkShell { name = "${projectName}-dev";
packages = with pkgs; [ pythonEnv git ];
PROJECT_NAME = projectName; PROJECT_VERSION = projectVersion;
shellHook = "echo '📦' ${projectName} v${projectVersion} && echo \"Python: $(python3 --version)\"";}Étape 10 : Figer les versions (reproductibilité)
Le problème avec <nixpkgs>
Jusqu’ici, nous utilisons import <nixpkgs> {}. Le problème ? <nixpkgs>
pointe vers le channel système qui change à chaque mise à jour. Aujourd’hui
vous avez Python 3.12.3, demain peut-être 3.12.4.
Pour un environnement vraiment reproductible, il faut figer la version de nixpkgs.
Solution 1 : Figer avec fetchTarball
{ pkgs ? import (fetchTarball { # nixpkgs 24.05 - figé à ce commit url = "https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gz"; sha256 = "sha256:1lr1h35prqkd1mkmzriwlpvxcb34kmhc9dnr48gkm8hh089hifmx"; }) {}}:
pkgs.mkShell { packages = [ pkgs.python312 ];}Maintenant, peu importe quand ou où vous lancez nix-shell, vous aurez
exactement la même version de Python.
Comment trouver le sha256 ?
Deux méthodes :
Méthode 1 : Laissez Nix le calculer (mettez une valeur bidon) :
sha256 = ""; # Nix affichera le bon hash dans l'erreurMéthode 2 : Utilisez nix-prefetch-url :
nix-prefetch-url --unpack https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gzSolution 2 : Figer avec un commit précis
Pour encore plus de précision, utilisez un commit spécifique :
{ pkgs ? import (fetchTarball { url = "https://github.com/NixOS/nixpkgs/archive/5ef6c425980847c78a80d759abc476e941a9bf42.tar.gz"; sha256 = "sha256:0123456789abcdef..."; }) {}}:
pkgs.mkShell { packages = [ pkgs.python312 ];}Quelle version d’un paquet ?
Pour voir quelle version d’un paquet est disponible :
# Dans le REPLnix replnix-repl> pkgs = import <nixpkgs> {}nix-repl> pkgs.python312.version"3.12.12"nix-repl> pkgs.nodejs.version"22.20.0"Trouver un paquet avec une version spécifique
Le site Nixhub.io ↗ permet de rechercher quelle version de nixpkgs contient une version précise d’un paquet.
Par exemple, si vous avez besoin de Python 3.11.4 exactement, Nixhub vous indiquera quel commit de nixpkgs utiliser.
Limites de cette approche
Cette méthode fonctionne, mais elle a des inconvénients :
- Pas de fichier de verrouillage partageable facilement
- Mise à jour manuelle des hashes
- Pas de gestion des dépendances entre projets
C’est pourquoi les flakes ont été créés : ils résolvent ces problèmes
avec un fichier flake.lock automatique. Un guide dédié aux flakes sera
bientôt disponible.
Syntaxe if-then-else
En Nix, if est une expression qui retourne une valeur :
if condition then valeur1 else valeur2Cas pratique : paquets conditionnels
Ajoutons des outils de debug uniquement en mode développement. Désormais, nous utilisons une version figée de nixpkgs :
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gz") {} }:
let debugMode = true; # Changez à false pour production
pythonEnv = pkgs.python312.withPackages (ps: [ ps.requests ps.flask ]);
# Outils de debug conditionnels debugTools = if debugMode then [ pkgs.htop pkgs.ncdu ] else [];inpkgs.mkShell { packages = with pkgs; [ pythonEnv git ] ++ debugTools; # ++ concatène les listes
shellHook = (if debugMode then "echo '🔧 Mode DEBUG activé'\n" else "");}L’opérateur ++ (concaténation)
++ concatène deux listes :
[ 1 2 ] ++ [ 3 4 ]# [ 1 2 3 4 ]Étape 12 : Modularisation avec import
Pourquoi modulariser ?
Notre shell.nix grandit. Séparons la configuration dans des fichiers distincts.
Structure du projet
mon-projet-python/├── shell.nix # Point d'entrée├── python-env.nix # Configuration Python└── config.nix # Variables de configurationconfig.nix : les variables
{ projectName = "mon-api"; projectVersion = "1.0.0"; debugMode = true; flaskPort = 5000;}python-env.nix : l’environnement Python
{ pkgs }:
pkgs.python312.withPackages (ps: [ ps.requests ps.flask ps.pytest ps.black ps.mypy])Ce fichier est une fonction qui attend pkgs en argument.
shell.nix : assemblage
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gz") {} }:
let # Import des modules config = import ./config.nix; pythonEnv = import ./python-env.nix { inherit pkgs; };
debugTools = if config.debugMode then with pkgs; [ htop ncdu ] else [];inpkgs.mkShell { name = "${config.projectName}-dev";
packages = with pkgs; [ pythonEnv git vim ripgrep ] ++ debugTools;
PROJECT_NAME = config.projectName; PROJECT_VERSION = config.projectVersion; FLASK_RUN_PORT = toString config.flaskPort;
}Vous avez remarqué qu’on demande à utiliser la version figée de nixpkgs dans
shell.nix et qu’on la transmet à python-env.nix via import ./python-env.nix { inherit pkgs; };.
Comprendre inherit
inherit pkgs est un raccourci pour pkgs = pkgs :
# Ces deux lignes sont équivalentes :import ./python-env.nix { pkgs = pkgs; }import ./python-env.nix { inherit pkgs; }C’est très courant pour passer des variables qui portent le même nom.
Étape 13 : L’opérateur // (merge)
Fusionner des attrsets
L’opérateur // fusionne deux attrsets. Le côté droit écrase le gauche :
{ a = 1; b = 2; } // { b = 3; c = 4; }# { a = 1; b = 3; c = 4; }Cas pratique : surcharges de configuration
Créons un système de configuration avec des valeurs par défaut :
let defaults = { projectName = "mon-projet"; debugMode = false; flaskPort = 5000; flaskHost = "127.0.0.1"; };
overrides = { debugMode = true; flaskPort = 8080; };in defaults // overrides# Résultat : debugMode=true, flaskPort=8080, le reste inchangéÉtape 14 : Fonctions utilitaires
Définir ses propres fonctions
Créons une fonction utilitaire pour formater les messages :
{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gz") {} }:
let config = import ./config.nix; pythonEnv = import ./python-env.nix { inherit pkgs; };
# Fonction utilitaire - retourne une chaîne pour shellHook formatBanner = title: version: '' echo "╔════════════════════════════════╗" echo "║ ${title} v${version}" echo "╚════════════════════════════════╝" '';inpkgs.mkShell { packages = [ pythonEnv pkgs.git ];
shellHook = formatBanner config.projectName config.projectVersion;}Fonctions avec attrset en argument
Pour plus de clarté, utilisez des arguments nommés :
let formatBanner = { title, version, debug ? false }: '' Project: ${title} v${version} '' + (if debug then "⚠️ DEBUG MODE\n" else "");in formatBanner { title = "mon-api"; version = "1.0.0"; debug = true; }Étape 15 : Builtins essentiels
Nix fournit des fonctions intégrées (builtins). Voici les plus utiles :
map : transformer une liste
map (x: x * 2) [ 1 2 3 ]# [ 2 4 6 ]Cas pratique - ajouter un préfixe à chaque élément :
let libs = [ "requests" "flask" "pytest" ];in map (lib: "python-${lib}") libs# [ "python-requests" "python-flask" "python-pytest" ]filter : filtrer une liste
builtins.filter (x: x > 2) [ 1 2 3 4 5 ]# [ 3 4 5 ]attrNames et attrValues
builtins.attrNames { a = 1; b = 2; c = 3; }# [ "a" "b" "c" ]
builtins.attrValues { a = 1; b = 2; c = 3; }# [ 1 2 3 ]Test d’existence avec ?
L’opérateur ? teste si un attribut existe :
let config = { port = 8080; };in config ? port # true config ? host # falseCombiné avec or pour des valeurs par défaut :
let config = { port = 8080; };in config.host or "localhost"# "localhost"Étape 16 : Configuration finale complète
Voici notre projet final avec tous les concepts :
mon-projet-python/├── shell.nix├── config.nix├── python-env.nix└── lib/ └── utils.nix{ # Retourne des commandes shell pour afficher une bannière formatBanner = { title, version, debug ? false }: '' echo "┌────────────────────────────────┐" echo "│ 🐍 ${title} v${version}" '' + (if debug then ''echo "│ ⚠️ Debug mode enabled"\n'' else "") + '' echo "└────────────────────────────────┘" '';
mkEnvVars = config: { PROJECT_NAME = config.projectName; PROJECT_VERSION = config.projectVersion; FLASK_RUN_PORT = toString config.flaskPort; FLASK_RUN_HOST = config.flaskHost; FLASK_DEBUG = if config.debugMode then "1" else "0"; };}let defaults = { projectName = "mon-api"; projectVersion = "1.0.0"; debugMode = false; flaskPort = 5000; flaskHost = "127.0.0.1"; };
# Surcharges pour le développement local localOverrides = { debugMode = true; flaskPort = 8080; };in defaults // localOverrides{ pkgs, config }:
let basePackages = ps: [ ps.requests ps.flask ps.python-dotenv ];
devPackages = ps: [ ps.pytest ps.black ps.mypy ps.ipython ];
allPackages = ps: basePackages ps ++ (if config.debugMode then devPackages ps else []);inpkgs.python312.withPackages allPackages{ pkgs ? import (fetchTarball "https://github.com/NixOS/nixpkgs/archive/nixos-24.05.tar.gz") {} }:
let # Imports config = import ./config.nix; utils = import ./lib/utils.nix; pythonEnv = import ./python-env.nix { inherit pkgs config; };
# Outils de base baseTools = with pkgs; [ git vim ripgrep jq ];
# Outils de debug debugTools = with pkgs; [ htop ncdu curl ];
# Tous les paquets allPackages = [ pythonEnv ] ++ baseTools ++ (if config.debugMode then debugTools else []);inpkgs.mkShell ({ name = "${config.projectName}-dev"; packages = allPackages;
shellHook = (utils.formatBanner { title = config.projectName; version = config.projectVersion; debug = config.debugMode; }) + "\necho \"Run 'flask run' to start the development server\"";} // utils.mkEnvVars config)Testez le résultat :
nix-shell# Affiche la bannière et les infosecho $FLASK_RUN_PORT # 8080echo $FLASK_DEBUG # 1python3 -c "import flask; print(flask.__version__)"Récapitulatif des concepts
| Concept | Syntaxe | Description |
|---|---|---|
| Fonctions | arg: corps | Tout est fonction en Nix. Les fichiers .nix sont souvent des fonctions qui attendent pkgs ou d’autres arguments. |
| Attrsets | { clé = valeur; } | Dictionnaires clé-valeur, structure fondamentale. On y accède avec . (ex: pkgs.git). |
| let … in | let x = 1; in x | Variables locales immuables. Permettent de structurer et réutiliser des valeurs. |
| with | with pkgs; | Importe les attributs d’un attrset dans le scope pour éviter les répétitions. |
| import | import ./file.nix | Charge et évalue un fichier Nix. Permet la modularisation en plusieurs fichiers. |
| inherit | inherit x | Raccourci pour x = x. Permet de passer des variables du même nom. |
| Opérateur // | a // b | Fusionne deux attrsets. Le droit écrase le gauche. Idéal pour les surcharges. |
| if-then-else | if c then a else b | Expression conditionnelle. Le else est obligatoire car tout doit retourner une valeur. |
Pour aller plus loin
Vous maîtrisez maintenant les bases du langage Nix en pratique. Voici les prochaines étapes pour approfondir :
- Flakes Nix (guide à venir) : workflow moderne avec verrouillage des dépendances et reproductibilité garantie
- NixOS : configurer un système d’exploitation entier de manière déclarative
Ressources complémentaires
| Ressource | Description |
|---|---|
| Nix Language ↗ | Documentation officielle du langage |
| Nix Pills ↗ | Tutoriel approfondi pas à pas |
| nix.dev ↗ | Guides pratiques et bonnes pratiques |
Conclusion
Le langage Nix peut sembler déroutant au premier abord, mais comme vous l’avez vu dans ce guide, ses concepts sont finalement simples : tout est expression, les attrsets structurent les données, les fonctions paramétrent les configurations, et l’immutabilité garantit la reproductibilité.
En construisant progressivement notre environnement Python, vous avez appris à lire et écrire du Nix de façon pratique. La prochaine étape naturelle sera d’explorer les flakes (guide à venir) pour bénéficier du verrouillage des dépendances et d’une structure de projet standardisée.