Featured image of post Managing Python Development Environments with NixOS

Managing Python Development Environments with NixOS

Context

I recently installed NixOS on my machine. I am currently working on some Python project using Visual Studio Code and was previously relying on Python virtual environments and a requirements.txt file to manage my dependencies. Visual Studio Code was smart enough to probe the working directory for virtual environments, and automatically use the one found as the default interpreter. However, using virtual environments and pip goes against Nix’s philosophy which provide a more powerful tool for development environments: nix-shell. The first part of this post presents how to setup this new environment, the second part digs a bit deeper into the internals of Python environments created by Nix.

Creating a Python Environment with nix-shell

As recommended when working on a Python project, I am using nix-shell to create a Python environment through the following Nix expression:

# in shell.nix
with import <nixpkgs> { };
let
  pythonEnv = python311.withPackages (ps: [
    ps.pandas
    ps.requests
    ps.gensim
    ps.numpy
    ps.matplotlib
    ps.mysql-connector
    ps.scikit-learn
    ps.sqlparse
    ps.tqdm
    ps.scipy
    ps.torch
    ps.transformers
    ps.sentence-transformers
    ps.torch-geometric
    ps.networkx
    ps.matplotlib-venn
    ps.jupyter
    ps.notebook
    ps.ipython
  ]);
in
pkgs.mkShell rec {
  packages = [
    pythonEnv
  ];
}

withPackages is a simplified wrapper around python.buildEnv that allows to create a Python environment by including the specified packages (in this case, pandas, request, gensim, …). Running nix-shell shell.nix creates an environment (through the resulting nix-shell) with access to a custom Python executable that includes our dependencies.

nix-shell is just another mechanism for dependency management that allows to share a developing environment across machines (a reproducible one when coupled with version pinning). A major benefit of using nix-shell is the ability to add dependencies that are not Python packages but still necessary for the project. For instance, we could add the graphviz package to manipulate created graph by networkx to the package field. The graphviz application would be included in our developpment environment but not globally.

Visual Studio Code integration

Now, when opening an instance of Visual Studio Code outside of our nix-shell, Visual Studio Code only sees the global Python interpreter without any dependencies: imports are broken so as semantic highlighting, and code navigation.

Visual Studio Code interpreter selector when Visual Studio Code is spawned outside nix-shell environment.

Visual Studio Code does not have access to the Python environment built with nix-shell. This means that to allow Visual Studio Code to access our environment we must invoke it from the nix-shell. Only then, it is able to find the Python environment, and we can manually select from the interpreter selection box.

Visual Studio Code interpreter selector when Visual Studio Code is spawned inside nix-shell environment.

However, Visual Studio Code sometimes keep in memory previously used interpreters. When adding a new package as a dependency to our project, a new Python environment is created and the interpreter path is different. Quickly, Visual Studio Code will reference many Python environments and knowing which one is is the correct one is not always trivial. Interpreter cache can be flushed using commands (Python: clear workspace interpreter settings & Python: Clear cache and reload windows) but that requires many inputs, and we still have to manually select the correct interpreter.

Visual Studio Code automatically choose a python interpreter by probing (in that order): virtual environments in the workspace, workspace related virtual environments and finally, globally installed interpreters. I don’t want to use virtual environments as Nix already built a Python environment with all necessary packages, nor can I rely on the automatic selection of the correct interpreter amongst the global ones, I would rather specify directly to Visual Studio Code which interpreter to use. Luckily, a Visual Studio Code setting does exactly that: python.defaultInterpreterPath. In your workspace settings you can create (or modify) a .vscode/settings.json file where you set the path to the default interpreter to some environment variable:

{
    "python.defaultInterpreterPath" : "${CUSTOM_INTERPRETER_PATH}"
}

And add a hook to the shell.nix file to export the environment variable accordingly:

[...]

pkgs.mkShell rec {
  packages = [
    pythonEnv
  ];
  
  shellHook = ''
    export CUSTOM_INTERPRETER_PATH="${pythonEnv}/bin/python"
  ''; 
}

These changes force Visual Studio Code into selecting the Python environment created by Nix for the workspace by using the CUSTOM_INTERPRETER_PATH environment variable. When rebuilding a new Python environment by adding or removing a package, simply re-invoke a new Visual Studio Code instance in the updated nix-shell.

Nix’s Python environment

Now, more on the Python environment. Using nix-shell on the previous Nix expression creates a temporary environment with access to all defined dependencies. Through withPackages, we are creating a custom Python executable that has access to the declared packages: a Python environment.

[user@~/repos/some-project]$ nix-shell shell.nix

[nix-shell:~/repos/some-project]$ which python3 

/nix/store/589qgvh8zrvyda825id44p77nbq8gqpf-python3-3.11.10-env/bin/python3

$ python3 
Python 3.11.10 (main, Sep  7 2024, 01:03:31) [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import transformers 
>>> import torch 
>>> import matplotlib_venn 
>>> 

/nix/store/589qgvh8zrvyda825id44p77nbq8gqpf-python3-3.11.10-env/bin/python3 is our custom Python executable specific to the declared environment and as we can see, it has access to our dependency packages. If we add or remove a package, a new Python environment will be created (reusing already downloaded or built packages, building or downloading the added ones).

But how does the custom Python3 executable locate the dependencies ? Usually, one can modify the PYTHONPATH environment variable to point towards packages. However, when creating child processes for a different project from the parent shell, these processes inherit the parent’s PYTHONPATH. This inheritance can cause conflicts in the development environment, as packages from the parent’s path may take precedence over the intended versions in the child environment.

Package resolution mechanism depends wether dependencies are included in the Python environment or outside it.

Packages included in Python Environments

Dependencies added to a Python environment through withPackages are first added to the Nix store, and then a symbolic link is added under the Python environment’s site-packages pointing to the module. In our case, the Python environment has been created under /nix/store/589qgvh8zrvyda825id44p77nbq8gqpf-python3-3.11.10-env/. We can find the locations Python is looking at when search for a package by inspecting sys.path:

[nix-shell:~/repos/some-project]$ python3 
Python 3.11.10 (main, Sep  7 2024, 01:03:31) [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> print("\n".join(sys.path))
/nix/store/nmqxyr00in2arwrq5qd1qipsanz1yrn5-python3-3.11.10/lib/python311.zip
/nix/store/nmqxyr00in2arwrq5qd1qipsanz1yrn5-python3-3.11.10/lib/python3.11
/nix/store/nmqxyr00in2arwrq5qd1qipsanz1yrn5-python3-3.11.10/lib/python3.11/lib-dynload
/nix/store/nmqxyr00in2arwrq5qd1qipsanz1yrn5-python3-3.11.10/lib/python3.11/site-packages
/nix/store/589qgvh8zrvyda825id44p77nbq8gqpf-python3-3.11.10-env/lib/python3.11/site-packages
>>> 

Examining the Python environment’s site-packages folder, we can retrieve the symbolic links pointing to all of our dependencies declared in the Python environment (and their runtime dependencies as well):

[nix-shell:~/repos/some-project]$  ls -la /nix/store/589qgvh8zrvyda825id44p77nbq8gqpf-python3-3.11.10-env/lib/python3.11/site-packages
...
lrwxrwxrwx 1 root root   103 janv.  1  1970 pandas -> 
  /nix/store/ykj2zgln73lsm8wr797wsc8b2mpdp6k8-python3.11-pandas-2.2.3/lib/python3.11/site-packages/pandas
lrwxrwxrwx 1 root root   119 janv.  1  1970 pandas-2.2.3.dist-info -> 
  /nix/store/ykj2zgln73lsm8wr797wsc8b2mpdp6k8-python3.11-pandas-2.2.3/lib/python3.11/site-packages/pandas-2.2.3.dist-info
...

This also reveals the usage of symbolic links by Nix to include packages from the store under our Python environment’s site-package directory. The transparent path resolution can be illustrated by revealing the module’s filepath:

[nix-shell:~/repos/xp-gaur]$ python3 
Python 3.11.10 (main, Sep  7 2024, 01:03:31) [GCC 13.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pandas
p>>> print(pandas.__file__)
/nix/store/589qgvh8zrvyda825id44p77nbq8gqpf-python3-3.11.10-env/lib/python3.11/site-packages/pandas/__init__.py
>>> 

Packages declared outside the Python Environment

On the other hand, other Python package dependencies that are not included in the Python environment (for instance, local packages added through callPackage) are imported using sitecustomize. Nix places such modules in the Python environment’s site-packages. During startup, Python tries to import this module to add custom locations in sys.path. In Nix’s implementation, the module reads the NIX_PYTHONPATH environment variable to append local packages (and their dependencies) to sys.path.

Conclusion

This post detailed how to create and manage Python development environments using nix-shell as well as the package resolution logic when declaring dependencies. Through nix-shell, Nix offers a more powerful alternative to traditional Python virtual environments by creating reproducible environments that manage both Python libraries and system dependencies in a declarative way. Moreover, nix-shell is not specific to Python projects but can be used with many languages and frameworks.