Skip to content

Instantly share code, notes, and snippets.

@bb010g
Last active November 6, 2024 11:58
Show Gist options
  • Save bb010g/8a28a7d1fcdb021b42d1da71d2429a4b to your computer and use it in GitHub Desktop.
Save bb010g/8a28a7d1fcdb021b42d1da71d2429a4b to your computer and use it in GitHub Desktop.
Example Python development environment with Nixpkgs

Example Python development environment with Nixpkgs

This project requires Python 3.6.

This project isn't packaged, and traditionally uses:

$ python -m venv venv
$ source venv/bin/activate
$ pip install "${pypi_deps[@]}"
$ pip freeze > requirements.txt

bareShell

Install direnv & lorri.

Our shell.nix then starts out simply, with just one build input:

{ pkgs ? import <nixpkgs> { } }:

pkgs.mkShell {
  name = "python-example-nixpkgs-dev-env";

  buildInputs = [
    (pkgs.python36.withPackages (pyPkgs: [
    ]))
  ];
}

This can now run an executable main.py with all the imports commented out.

slightlyLessBareShell

If you want to add packages, it's all ready to. This example works with just uncommenting urllib3's import.

(python.withPackages (pyPkgs: [
  pyPkgs.urllib3
]))

venvShellHookShell

By using pythonPackages.venvShellHook (see the Nixpkgs manual, §15.17.3.6. How to consume python modules using pip in a virtual environment like I am used to on other Operating Systems?), a traditional venv-based Python development environment can be established.

pkgs.mkShell {
  buildInputs = [
    python
    pythonPackages.venvShellHook
  ];
}

Unfortunately, this doesn't quite carry over to direnv & lorri that well (see also nix-direnv#32), as venvShellHook's hook uses a function. However, we can replicate its effects in .envrc by looking at what <nixpkgs/pkgs/development/interpreters/python/hooks/venv-shell-hook.sh> does:

# pre(venvShellHook)
unset venvDir
# lorri
eval "$(lorri direnv)"
# venvShellHook
if [[ -v venvDir ]]; then
  if [[ -d "${venvDir}" ]]; then
    printf "%s\n" "Skipping venv creation, '${venvDir}' already exists"
    source "${venvDir}/bin/activate"
  else
    printf "%s\n" "Creating new venv environment in path: '${venvDir}'"
    python -m venv "${venvDir}"
    source "${venvDir}/bin/activate"
  fi
fi

Only running if $venvDir is defined by the Nix shell allows this .envrc to be used with or without venvShellHook present in the shell.

With that all done, pip works. To ensure things are set up properly, let's fully install the venv in a pure Nix shell.

$ rm -rf .venv && nix-shell --pure --run 'pip install -r requirements.txt'

pip install fails with an error on kappa==0.6.0.

    Complete output from command python setup.py egg_info:
    Traceback (most recent call last):
      File "<string>", line 1, in <module>
      File "/run/user/1000/pip-install-gydqfy_d/kappa/setup.py", line 54, in <module>
        run_setup()
      File "/run/user/1000/pip-install-gydqfy_d/kappa/setup.py", line 22, in run_setup
        long_description=open_file('README.rst').read(),
      File "/nix/store/5rxscqabwp1mkq0x11x04bbh5gvqdkad-python3-3.6.10/lib/python3.6/encodings/ascii.py", line 26, in decode
        return codecs.ascii_decode(input, self.errors)[0]
    UnicodeDecodeError: 'ascii' codec can't decode byte 0xe2 in position 2339: ordinal not in range(128)

This is a problem that you normally don't have to deal with in Nixpkgs. Amending LANG = "C.UTF-8"; to your shell attributes fixes it, though.

And ./main.py succeeds with all dependencies uncommented.

(If you stop using this shell, don't forget to remove .venv.)

machNixShell

mach-nix provides easier environments from requirements.txt.

mach-nix is not included in Nixpkgs, so we'll pin a version (the latest release's tag at time of writing).

$ niv add github DavHau/mach-nix -r 2.3.0

Expose mach-nix's expressions (alongside niv's expression):

import pkgsPath {
  config = { };
  overlays = [ (pkgsSuper: pkgs: {
    mach-nix = import sources.mach-nix;
    inherit (import sources.niv { }) niv;
  }) ];
}

And write the shell (a bit strange, but it works):

(pkgs.mach-nix.mkPythonShell {
  requirements = builtins.readFile ./requirements.txt;
  inherit pkgs python;
}).overrideAttrs (oldAttrs: {
  name = "${name}-mach-nix";
  nativeBuildInputs = oldAttrs.nativeBuildInputs or [ ] ++ [
    pkgs.niv
  ];
})

shell.nix no longer evaluates, though. From nix-shell --pure --show-trace --run ':' (a no-op on success):

Some requirements could not be resolved.
Top level requirements:
  Flask==1.1.1  Flask-Cors==3.0.8  Flask-JWT-Extended==3.23.0  Flask-SQLAlchemy==2.4.1  Flask-Migrate==2.5.3  Jinja2==2.10.1  passlib==1
.7.1  PyMySQL==0.9.3  pymssql==2.1.4  PyYAML==5.1.2  requests==2.22.0  SQLAlchemy==1.3.8  urllib3==1.25.6  Werkzeug==0.16.0  zappa==0.48
.2  cx-Oracle==7.2.2  pytz==2019.3
Providers:
  {'_default': 'wheel,sdist,nixpkgs',
   'gdal': 'nixpkgs',
   'pip': 'nixpkgs',
   'setupmeta': 'wheel',
   'setuptools': 'nixpkgs',
   'wheel': 'nixpkgs,sdist'}
Mach-nix version: 2.3.0
Python: 3.6.10
Cause: None
The requirements which caused the error:
  kappa==0.6.0 - parent: zappa:0.48.2

I have no idea what's going on here.

This Tutorial Is ~Under Construction~

DavHau/mach-nix#105

niv

Throughout these examples, the sources.json & sources.nix files and sources expression are provided and managed by niv. niv is an easy way to pin dependencies for your Nix expressions, and easily update them later.

niv is already set up here, and will just work for updates. If you want to install from scratch (e.g. into a new repository), nix-shell -p niv will get you bootstrapped until you can import it from sources into your own shell.

niv's documentation explains all this pretty well.

# pre(venvShellHook)
unset venvDir
# lorri
eval "$(lorri direnv)"
# venvShellHook
if [[ -v venvDir ]]; then
if [[ -d "${venvDir}" ]]; then
printf "%s\n" "Skipping venv creation, '${venvDir}' already exists"
source "${venvDir}/bin/activate"
else
printf "%s\n" "Creating new venv environment in path: '${venvDir}'"
python -m venv "${venvDir}"
source "${venvDir}/bin/activate"
fi
fi
# Nix
/result*
# Python
## venv
/.venv
--- a/mach_nix/generate.py
+++ b/mach_nix/generate.py
@@ -1,6 +1,7 @@
import json
import os
import sys
+import traceback
from os.path import dirname
from pprint import pformat
from typing import List
@@ -88,6 +89,8 @@ def handle_resolution_impossible(exc: ResolutionImpossible, reqs_str, providers_
f"The requirements which caused the error:"
f"{causes_str}\n",
file=sys.stderr)
+ if exc.__context__ is None:
+ traceback.print_exc(file=sys.stderr)
if __name__ == "__main__":
diff --git a/mach_nix/nix/mach.nix b/mach_nix/nix/mach.nix
index b09964f..768c7bf 100644
--- a/mach_nix/nix/mach.nix
+++ b/mach_nix/nix/mach.nix
@@ -37,10 +37,12 @@ let
inherit disable_checks nixpkgs_json requirements pypi_deps_db_src pypi_fetcher_commit pypi_fetcher_sha256;
providers = providers_json;
py_ver_str = python.version;
+ LANG = "C.UTF-8";
}
''
mkdir -p $out/share
export out_file=$out/share/mach_nix_file.nix
+ export LANG
export PYTHONPATH=${src}
${builder_python}/bin/python ${src}/mach_nix/generate.py
'';
let
mach-nix-src-boot = builtins.fetchGit {
url = "https://github.com/DavHau/mach-nix/";
ref = "refs/tags/2.3.0";
};
mach-nix-boot = import mach-nix-src-boot;
pkgs-boot = mach-nix-boot.nixpkgs;
mach-nix-src = let
src = mach-nix-src-boot;
patches = [
(builtins.toFile "mach-nix-dump.patch" ''
--- a/mach_nix/generate.py
+++ b/mach_nix/generate.py
@@ -1,6 +1,7 @@
import json
import os
import sys
+import traceback
from os.path import dirname
from pprint import pformat
from typing import List
@@ -88,6 +89,7 @@ def handle_resolution_impossible(exc: ResolutionImpossible, reqs_str, providers_
f"The requirements which caused the error:"
f"{causes_str}\n",
file=sys.stderr)
+ traceback.print_exc(file=sys.stderr)
if __name__ == "__main__":
'')
];
in if patches == [ ] then src else pkgs-boot.applyPatches {
name = "match-nix-src-patched";
inherit src patches;
};
mach-nix = import mach-nix-src;
pkgs = mach-nix.nixpkgs;
machNixFile-src = "${mach-nix-src}/mach_nix/nix/mach.nix";
machNixFile = import machNixFile-src;
in machNixFile {
python = pkgs.python36; # failure
# python = pkgs.python37; # success
requirements = ''
kappa==0.6.0
'';
}
#!/usr/bin/env python
from flask import Flask
from flask_cors import CORS
from flask_jwt_extended import JWTManager
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from jinja2 import Template
from passlib.hash import pbkdf2_sha256
from pymysql.connections import Connection
try:
from yaml import CLoader
print('PyYAML w/ LibYAML')
except ImportError:
from yaml import Loader
from requests import Response
from sqlalchemy import ForeignKeyConstraint
from urllib3 import PoolManager
from werkzeug.wrappers import Request
from zappa.core import Zappa
from cx_Oracle import SodaDatabase
from pytz import timezone
def main():
print('Hello, World!')
if __name__ == '__main__':
main()
Flask==1.1.1
Flask-Cors==3.0.8
Flask-JWT-Extended==3.23.0
Flask-SQLAlchemy==2.4.1
Flask-Migrate==2.5.3
Jinja2==2.10.1
passlib==1.7.1
PyMySQL==0.9.3
pymssql==2.1.4
PyYAML==5.1.2
requests==2.22.0
SQLAlchemy==1.3.8
urllib3==1.25.6
Werkzeug==0.16.0
zappa==0.48.2
cx-Oracle==7.2.2
pytz==2019.3
{ sources ? import ./sources.nix
, pkgsPath ? sources.nixpkgs or <nixpkgs>
, pkgsBoot ? import pkgsPath { config = { }; overlays = [ ]; }
, pkgs ? import pkgsPath {
config = { };
overlays = [ (pkgsSuper: pkgs: {
mach-nix = let
src = sources.mach-nix;
patches = [
# ./mach-nix-locale.patch
];
in import (if patches == [ ] then src else pkgsBoot.applyPatches rec {
name = "mach-nix-src-patched";
inherit src patches;
});
niv = (import sources.niv { }).niv;
}) ];
}
}:
let
name = "python-example-nixpkgs-dev-env";
python = pkgs.python36;
pythonPackages = python.pkgs;
bareShell = pkgs.mkShell {
name = "${name}-bare";
nativeBuildInputs = [
pkgs.niv
];
buildInputs = [
(python.withPackages (pyPkgs: [
]))
];
};
slightlyLessBareShell = pkgs.mkShell {
name = "${name}-slightly-less-bare";
nativeBuildInputs = [
pkgs.niv
];
buildInputs = [
(python.withPackages (pyPkgs: [
pyPkgs.urllib3
]))
];
};
venvShellHookShell = pkgs.mkShell {
name = "${name}-venv-shell-hook";
venvDir = "./.venv";
nativeBuildInputs = [
pkgs.niv
];
buildInputs = [
python
pythonPackages.venvShellHook
];
LANG = "C.UTF-8";
};
machNixShell = (pkgs.mach-nix.mkPythonShell {
requirements = builtins.readFile ./requirements.txt;
inherit pkgs python;
}).overrideAttrs (oldAttrs: {
name = "${name}-mach-nix";
nativeBuildInputs = oldAttrs.nativeBuildInputs or [ ] ++ [
pkgs.niv
pkgs.buildPackages.glibcLocales
];
});
in bareShell
{
"mach-nix": {
"branch": "master",
"description": "Tool to create highly reproducible python environments",
"homepage": null,
"owner": "DavHau",
"repo": "mach-nix",
"rev": "2.3.0",
"sha256": "0scz4qm8yjighcdjnl8msis3dpb7h9qxwys7cas7raid0h3964ij",
"type": "tarball",
"url": "https://github.com/DavHau/mach-nix/archive/2.3.0.tar.gz",
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
},
"niv": {
"branch": "master",
"description": "Easy dependency management for Nix projects",
"homepage": "https://github.com/nmattia/niv",
"owner": "nmattia",
"repo": "niv",
"rev": "89ae775e9dfc2571f912156dd2f8627e14d4d507",
"sha256": "0ssw6byyn79fpyzswi28s5b85x66xh4xsfhmcfl5mkdxxpmyy0ns",
"type": "tarball",
"url": "https://github.com/nmattia/niv/archive/89ae775e9dfc2571f912156dd2f8627e14d4d507.tar.gz",
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
},
"nixpkgs": {
"branch": "release-20.03",
"description": "Nix Packages collection",
"homepage": null,
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "2739290bb0c9ca7b77d0e641e608337198dcffdb",
"sha256": "1h14cqmgxg6lm8gynvvkz3p7xxw9ilblp1jkrn5l3rbqbvyxf9n0",
"type": "tarball",
"url": "https://github.com/NixOS/nixpkgs/archive/2739290bb0c9ca7b77d0e641e608337198dcffdb.tar.gz",
"url_template": "https://github.com/<owner>/<repo>/archive/<rev>.tar.gz"
}
}
# This file has been generated by Niv.
let
#
# The fetchers. fetch_<type> fetches specs of type <type>.
#
fetch_file = pkgs: name: spec:
let
name' = sanitizeName name + "-src";
in
if spec.builtin or true then
builtins_fetchurl { inherit (spec) url sha256; name = name'; }
else
pkgs.fetchurl { inherit (spec) url sha256; name = name'; };
fetch_tarball = pkgs: name: spec:
let
name' = sanitizeName name + "-src";
in
if spec.builtin or true then
builtins_fetchTarball { name = name'; inherit (spec) url sha256; }
else
pkgs.fetchzip { name = name'; inherit (spec) url sha256; };
fetch_git = name: spec:
let
ref =
if spec ? ref then spec.ref else
if spec ? branch then "refs/heads/${spec.branch}" else
if spec ? tag then "refs/tags/${spec.tag}" else
abort "In git source '${name}': Please specify `ref`, `tag` or `branch`!";
in
builtins.fetchGit { url = spec.repo; inherit (spec) rev; inherit ref; };
fetch_local = spec: spec.path;
fetch_builtin-tarball = name: throw
''[${name}] The niv type "builtin-tarball" is deprecated. You should instead use `builtin = true`.
$ niv modify ${name} -a type=tarball -a builtin=true'';
fetch_builtin-url = name: throw
''[${name}] The niv type "builtin-url" will soon be deprecated. You should instead use `builtin = true`.
$ niv modify ${name} -a type=file -a builtin=true'';
#
# Various helpers
#
# https://github.com/NixOS/nixpkgs/pull/83241/files#diff-c6f540a4f3bfa4b0e8b6bafd4cd54e8bR695
sanitizeName = name:
(
concatMapStrings (s: if builtins.isList s then "-" else s)
(
builtins.split "[^[:alnum:]+._?=-]+"
((x: builtins.elemAt (builtins.match "\\.*(.*)" x) 0) name)
)
);
# The set of packages used when specs are fetched using non-builtins.
mkPkgs = sources: system:
let
sourcesNixpkgs =
import (builtins_fetchTarball { inherit (sources.nixpkgs) url sha256; }) { inherit system; };
hasNixpkgsPath = builtins.any (x: x.prefix == "nixpkgs") builtins.nixPath;
hasThisAsNixpkgsPath = <nixpkgs> == ./.;
in
if builtins.hasAttr "nixpkgs" sources
then sourcesNixpkgs
else if hasNixpkgsPath && ! hasThisAsNixpkgsPath then
import <nixpkgs> {}
else
abort
''
Please specify either <nixpkgs> (through -I or NIX_PATH=nixpkgs=...) or
add a package called "nixpkgs" to your sources.json.
'';
# The actual fetching function.
fetch = pkgs: name: spec:
if ! builtins.hasAttr "type" spec then
abort "ERROR: niv spec ${name} does not have a 'type' attribute"
else if spec.type == "file" then fetch_file pkgs name spec
else if spec.type == "tarball" then fetch_tarball pkgs name spec
else if spec.type == "git" then fetch_git name spec
else if spec.type == "local" then fetch_local spec
else if spec.type == "builtin-tarball" then fetch_builtin-tarball name
else if spec.type == "builtin-url" then fetch_builtin-url name
else
abort "ERROR: niv spec ${name} has unknown type ${builtins.toJSON spec.type}";
# If the environment variable NIV_OVERRIDE_${name} is set, then use
# the path directly as opposed to the fetched source.
replace = name: drv:
let
saneName = stringAsChars (c: if isNull (builtins.match "[a-zA-Z0-9]" c) then "_" else c) name;
ersatz = builtins.getEnv "NIV_OVERRIDE_${saneName}";
in
if ersatz == "" then drv else ersatz;
# Ports of functions for older nix versions
# a Nix version of mapAttrs if the built-in doesn't exist
mapAttrs = builtins.mapAttrs or (
f: set: with builtins;
listToAttrs (map (attr: { name = attr; value = f attr set.${attr}; }) (attrNames set))
);
# https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/lists.nix#L295
range = first: last: if first > last then [] else builtins.genList (n: first + n) (last - first + 1);
# https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L257
stringToCharacters = s: map (p: builtins.substring p 1 s) (range 0 (builtins.stringLength s - 1));
# https://github.com/NixOS/nixpkgs/blob/0258808f5744ca980b9a1f24fe0b1e6f0fecee9c/lib/strings.nix#L269
stringAsChars = f: s: concatStrings (map f (stringToCharacters s));
concatMapStrings = f: list: concatStrings (map f list);
concatStrings = builtins.concatStringsSep "";
# https://github.com/NixOS/nixpkgs/blob/8a9f58a375c401b96da862d969f66429def1d118/lib/attrsets.nix#L331
optionalAttrs = cond: as: if cond then as else {};
# fetchTarball version that is compatible between all the versions of Nix
builtins_fetchTarball = { url, name ? null, sha256 }@attrs:
let
inherit (builtins) lessThan nixVersion fetchTarball;
in
if lessThan nixVersion "1.12" then
fetchTarball ({ inherit url; } // (optionalAttrs (!isNull name) { inherit name; }))
else
fetchTarball attrs;
# fetchurl version that is compatible between all the versions of Nix
builtins_fetchurl = { url, name ? null, sha256 }@attrs:
let
inherit (builtins) lessThan nixVersion fetchurl;
in
if lessThan nixVersion "1.12" then
fetchurl ({ inherit url; } // (optionalAttrs (!isNull name) { inherit name; }))
else
fetchurl attrs;
# Create the final "sources" from the config
mkSources = config:
mapAttrs (
name: spec:
if builtins.hasAttr "outPath" spec
then abort
"The values in sources.json should not have an 'outPath' attribute"
else
spec // { outPath = replace name (fetch config.pkgs name spec); }
) config.sources;
# The "config" used by the fetchers
mkConfig =
{ sourcesFile ? if builtins.pathExists ./sources.json then ./sources.json else null
, sources ? if isNull sourcesFile then {} else builtins.fromJSON (builtins.readFile sourcesFile)
, system ? builtins.currentSystem
, pkgs ? mkPkgs sources system
}: rec {
# The sources, i.e. the attribute set of spec name to spec
inherit sources;
# The "pkgs" (evaluated nixpkgs) to use for e.g. non-builtin fetchers
inherit pkgs;
};
in
mkSources (mkConfig {}) // { __functor = _: settings: mkSources (mkConfig settings); }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment