Nix for Python, attempt number 1
Disclaimer: This is less of a tutorial and more of a record of my past experience—in particular, I end up not reaching my stated goal for this post.
I’ve been reading this book on Hypermedia Systems, and one of the things they do is develop a small CRUD application in Python for which the code can be found here. Like the good software citizens they are, they’ve included a full detailed list of their project dependencies as a requirements.txt
file.
However, if I, a budding hypermedia aficionado, wished to run and even improve on their work, and I didn’t know Python, I’d enjoy a “one method to rule them all” to, say, install all the dependencies and drop myself into a shell exposing all of these dependencies. This is where Nix comes in.
AFAIU, Nix offers a way to declare dependencies of a software project, totally and precisely. With this, you can create isolated environments that contain exactly what is needed to run that software.
In this post, we’ll be writing a flake.nix
file which encodes all of that information and will make development safer and more streamlined.
The official wiki gives instructions on how to use flakes, which are an experimental (yet widely used) feature of the Nix ecosystem. Let’s start here!
I fork the hypermedia project repository and clone it on my machine. I create an initial flake.nix
with
1nix flake init
and stage it (otherwise Nix won’t take it into account):
1git add flake.nix
Here are the contents of our dummy flake file:
1{
2 description = "A very basic flake";
3
4 outputs = { self, nixpkgs }: {
5
6 packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello;
7
8 packages.x86_64-linux.default = self.packages.x86_64-linux.hello;
9
10 };
11}
What is this supposed to mean?
The Nix wiki page can tell us about that:
The flake.nix file is a Nix file but that has special restrictions (more on that later).
It has 4 top-level attributes:
description
is a string describing the flake.inputs
is an attribute set of all the dependencies of the flake. The schema is described below.outputs
is a function of one argument that takes an attribute set of all the realized inputs, and outputs another attribute set which schema is described below.nixConfig
is an attribute set of values which reflect the values given to nix.conf. This can extend the normal behavior of a user’s nix experience by adding flake-specific configuration, such as a binary cache.
So, our basic flake has the description
attribute and the outputs
attribute; the latter is a function that takes an attribute set as input and outputs some stuff. The attribute set contains two attributes, self
and nixpkgs
.
Neither of these two attributes are defined elsewhere in the file, so I guess these names are known by the Nix system—although I don’t know what they refer to yet.
The flake doesn’t have inputs
or nixConfig
attributes, so I guess they’re not strictly necessary, or perhaps the Nix system fills them with default values on its own.
I see in the output schema that one possible standard attribute is devShells
. This sounds like the kind of place one might specify packages to install in an isolated development environment.
Doing a little digging, it would seem that the devenv project seeks to simplify the process of specifying development environments even more. Let’s take a look at their Flakes tutorial.
They recommend creating the flake file using their own template:
1nix flake init --template github:cachix/devenv
I run this, after deleting the old flake.nix
—I can always get it back with nix flake init
.
The new flake file we get is:
1{
2 inputs = {
3 nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.11";
4 systems.url = "github:nix-systems/default";
5 devenv.url = "github:cachix/devenv";
6 };
7
8 outputs = { self, nixpkgs, devenv, systems, ... } @ inputs:
9 let
10 forEachSystem = nixpkgs.lib.genAttrs (import systems);
11 in
12 {
13 devShells = forEachSystem
14 (system:
15 let
16 pkgs = nixpkgs.legacyPackages.${system};
17 in
18 {
19 default = devenv.lib.mkShell {
20 inherit inputs pkgs;
21 modules = [
22 {
23 # https://devenv.sh/reference/options/
24 packages = [ pkgs.hello ];
25
26 enterShell = ''
27 hello
28 '';
29 }
30 ];
31 };
32 });
33 };
34}
This flake has the inputs
and outputs
attributes, but it no longer has a description
. Oh well, wasn’t too important I guess.
Of interest might be the systems
value. My hunch is that this lets us iterate over a number of possible system architectures (linux, darwin AKA MacOS, windows…) so that we only have to declare our dependencies once and the flake should still work for all of these systems. I might take the time to research this later, but it’s out of scope for now.
Okay, so how do we declare that we want Python installed?
The devenv team have written example flakes for many types of projects, including for Python projects using virtualenv and for those using poetry (another reproducible software solution). I don’t really see the point of using poetry for now, so let’s stick to venv for the time being. Besides, I know from experience that some packages are currently difficult to install through poetry.
Side note: it seems that a process similar to this one is discussed in this Tweag blog post, except they focus on projects using poetry. Side side note: if you don’t follow Tweag’s blog, you’re missing out!
Let’s add one line to flake.nix
to have Python installed:
1{
2 ...
3 modules = [
4 {
5 packages = ...
6
7 # Install Python, please!
8 languages.python.enable = true;
9
10 enterShell = ...
11 ...
12}
Oh, and devenv also created a .envrc
file for use with direnv, a program that automatically loads stuff when you cd
into the project directory. The contents of that file will always be the same when using Nix flakes, as all of the loading logic will be described in the flake.
If you have direnv installed, it should be enough to just change flake.nix
. If you don’t, try opening the shell with
1nix develop --impure
as explained in the devenv tutorial.
After getting into the shell, you should see that the python
executable is available and maps to somewhere in the Nix store:
1$ python --version
2Python 3.10.11
3
4$ which python
5/nix/store/x67wafvv3r3ndf2c959qf85nwk1qd9d9-devenv-profile/bin/python
However, predictably, the app won’t run because the Python packages are not installed:
1$ python app.py
2Traceback (most recent call last):
3 File "/Users/Auguste/Desktop/contact-app/app.py", line 1, in <module>
4 from flask import (
5ModuleNotFoundError: No module named 'flask'
So, what do we do now?
Ideally, we would just say in flake.nix
which packages we need and get the guarantee that Nix will find a combination of versions that works. However, we cannot expect Nix to know how to do that for all past and future languages with package management capabilities.
Since we already have a list of packages and their versions that supposedly works, in the form of a requirements.txt
, we should be able to somehow tell Nix to use those.
It turns out pip2nix
does something like this.
I follow the instructions in their README to get a working pip2nix
executable which I can run on the requirements.txt
file. I tried to use the commands verbatim, but those work for Python 3.6, which is too old for the package versions listed in requirements.txt
. Replacing python36
by python39
(the latest version supported by pip2nix
at the moment) made everything work.
When this succeeds, we get a python-packages.nix
file containing precise information on how to build the project dependencies, written in Nix.
We need to pipe this into our flake file, but where?
Our Python packages are project dependencies, so based on the Nix wiki, it would make sense for them to fit in the inputs
attribute of the flake. However, the inputs are supposed to be flakes themselves, which our python packages aren’t. So, are we done? I suspect there is a way to create some ad hoc flakes that serve only for pleasing Nix, but I don’t know how to do that. ^^'
A little later, I settled for an easier yet less reproducible solution: install virtualenv and install the dependencies stated in requirements.txt
using pip.This requires adding this line to flake.nix
:
1{
2 ...
3 modules = [
4 {
5 packages = ...
6
7 # Install Python, please!
8 languages.python.enable = true;
9 # Also virtualenv, please!
10 languages.python.virtualenv.enable = true;
11
12 enterShell = ...
13 ...
14}
After that, Nix will do what it needs to do, and after I am dropped into the devshell I can run
1pip install -r requirements.txt
In conclusion, even within Nix, there are many concurrent ways to make the process of tracking dependencies easier—and I’ll need to do some more digging to find the right one for me. For example, dream2nix aims to be the one tool to be used for solving this for all languages, but it unstable and only supports a handful of languages. Perhaps I’ll try their “Build you Python project in 10 minutes” tutorial in a future post.
Final note: This post relies on the assumption that the app of interest will not be further developed; keeping the dependencies synced between requirements.txt
and flake.nix
during development is a problem for another time. However, Nix can still help us at least easily and reliably run code, even long after it was written.
This is post number 001 of #100daystooffload.