From 7b90b4f12af1f0223c1382e0380679e7f2c1d8a3 Mon Sep 17 00:00:00 2001 From: Damien Wilson Date: Wed, 17 Jun 2026 11:44:32 -0400 Subject: [PATCH] Add Nix dev shell for backend and frontend `nix develop` provides the toolchain needed to contribute: Python 3.13, Node 22, Postgres 16, make, git, the docker CLI, and the native libs Python wheels may build against. Nix supplies the toolchain only; project dependencies are still installed by `make dev` and `npm install`, so requirements*.txt and package-lock.json stay authoritative. Toolchain versions are read at eval time from the files that own them (package.json engines, tox.ini, the Makefile) rather than duplicated, so the flake can't drift from them; a source whose format changes, or a version nixpkgs lacks, fails at eval with a message naming the cause. The resolved Node is also checked against engines.node's full floor, since nixpkgs is selected by major and supplies the patch. The shell hook lives in nix/dev-shell-hook.sh (sourced, with values passed as env vars) so it stays shellcheck-clean and out of nixfmt's string reindentation. flake.lock is committed for reproducibility. --- .gitignore | 3 + flake.lock | 61 +++++++++++++++++ flake.nix | 150 ++++++++++++++++++++++++++++++++++++++++++ nix/dev-shell-hook.sh | 44 +++++++++++++ 4 files changed, 258 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/dev-shell-hook.sh diff --git a/.gitignore b/.gitignore index e94e53b0..09c0ecd1 100644 --- a/.gitignore +++ b/.gitignore @@ -126,6 +126,9 @@ ENV/ env.bak/ venv.bak/ +# Nix dev shell — project-local npm global prefix (see flake.nix shellHook) +.nix-npm-global/ + # Spyder project settings .spyderproject .spyproject diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..225d4a91 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1781577229, + "narHash": "sha256-lrp67w8AulE9Ks53n27I45ADSzbOCn4H+CNW1Ck8B+8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "567a49d1913ce81ac6e9582e3553dd90a955875f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..0d263f50 --- /dev/null +++ b/flake.nix @@ -0,0 +1,150 @@ +{ + description = "Dev shell for ACCESS: FastAPI (Python) backend and Vite/React (Node) frontend"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { inherit system; }; + inherit (pkgs) lib; + + # Toolchain versions are owned by other files in the repo (package.json, + # tox.ini, the Makefile). Read them here at eval time rather than + # duplicating the pins. Project dependencies themselves are still + # installed by `make dev` and `npm install`, so requirements*.txt and + # package-lock.json remain authoritative for those; Nix only supplies a + # matching language toolchain. + # + # If a source file's format changes, the relevant parser below throws at + # eval time instead of selecting a wrong version. + + # Look up a package attr (e.g. "python313") by name, throwing if nixpkgs + # does not have it. + pkgByName = + what: attr: + pkgs.${attr} or ( + throw "flake.nix: nixpkgs has no '${attr}' (derived for ${what}). " + + "Either the canonical source moved to a version nixpkgs lacks, " + + "or the nixpkgs input needs bumping." + ); + + # Return the first capture group of `re` applied to `text`, or throw. + # `re` must hold one capture group and match the whole newline-flattened + # text; callers include their own anchors. Matching the full string + # rather than wrapping in `.*...*` keeps greedy backtracking from + # splitting a multi-segment version like "22.12.0". + matchOr = + { + file, + re, + hint, + }: + text: + let + m = builtins.match re (lib.replaceStrings [ "\n" ] [ " " ] text); + in + if m == null then + throw "flake.nix: could not parse ${file} (${hint}). The dev shell derives a version from it; update the regex in flake.nix if the file's format changed." + else + builtins.head m; + + # Versions parsed from their source files. + pkgJson = builtins.fromJSON (builtins.readFile ./package.json); + engines = + pkgJson.engines + or (throw "flake.nix: package.json has no \"engines\" block to derive node/npm from."); + nodeFloor = matchOr { + file = "package.json engines.node"; + re = "[^0-9]*([0-9.]+).*"; # ">=22.12.0" -> "22.12.0" + hint = "expected a semver like >=22.12.0"; + } engines.node; + npmVersion = matchOr { + file = "package.json engines.npm"; + re = "[^0-9]*([0-9.]+).*"; # ">=11.16.0" -> "11.16.0" + hint = "expected a semver like >=11.16.0"; + } engines.npm; + pyDigits = matchOr { + file = "tox.ini envlist"; + re = ".*py[{]?([0-9]+).*"; # "py{313}" -> "313" + hint = "expected an env like py313 or py{313}"; + } (builtins.readFile ./tox.ini); + pgMajor = matchOr { + file = "Makefile"; + re = ".*postgres:([0-9]+).*"; # "postgres:16" -> "16" + hint = "expected a docker image ref like postgres:16"; + } (builtins.readFile ./Makefile); + + # Resolved toolchain packages. + python = pkgByName "Python from tox.ini (py${pyDigits})" "python${pyDigits}"; + postgresql = pkgByName "Postgres from Makefile (postgres:${pgMajor})" "postgresql_${pgMajor}"; + + # nixpkgs is selected by major and supplies the patch, so also check its + # version against engines.node's full floor: a raised floor the pinned + # nixpkgs can't meet then fails at eval rather than yielding an old Node. + nodejs = + let + major = lib.versions.major nodeFloor; + pkg = pkgByName "Node from package.json engines.node (>=${nodeFloor})" "nodejs_${major}"; + in + lib.throwIf (!lib.versionAtLeast pkg.version nodeFloor) ( + "flake.nix: nixpkgs nodejs_${major} is ${pkg.version}, but package.json " + + "engines.node requires >=${nodeFloor}. Bump the nixpkgs input to one " + + "that ships a new enough Node ${major}.x." + ) pkg; + in + { + devShells.default = pkgs.mkShell { + name = "access-dev"; + + packages = with pkgs; [ + # --- Backend toolchain --- + python + python.pkgs.pip + python.pkgs.virtualenv + + # --- Frontend toolchain --- + nodejs # major derived from package.json engines.node; npm bumped in-shell + + # --- Database (for `make pytest-postgres`, alembic, psql) --- + postgresql # major derived from the Makefile's postgres:NN image + + # --- Build / dev utilities --- + gnumake + git + docker-client # `docker` / `docker compose` CLI for the make docker targets + + # --- Build deps for Python wheels that compile from source + # (cryptography, asyncpg, etc.) when no wheel matches --- + stdenv.cc.cc.lib + openssl + libffi + zlib + ]; + + env = { + PIP_CONSTRAINT = "constraints.txt"; + # Let pip-built native extensions find the Nix-provided libs. + LD_LIBRARY_PATH = lib.makeLibraryPath [ + pkgs.stdenv.cc.cc.lib + pkgs.openssl + pkgs.zlib + ]; + }; + + # Hook lives in a real .sh file so it stays shellcheck-able and free of + # nixfmt's string reindentation; values it needs are passed as env vars. + shellHook = '' + export NPM_VERSION=${npmVersion} + export PYTHON=${python.interpreter} + source ${./nix/dev-shell-hook.sh} + ''; + }; + } + ); +} diff --git a/nix/dev-shell-hook.sh b/nix/dev-shell-hook.sh new file mode 100644 index 00000000..52399ca7 --- /dev/null +++ b/nix/dev-shell-hook.sh @@ -0,0 +1,44 @@ +# shellcheck shell=bash +# Dev shell hook for flake.nix. NPM_VERSION and PYTHON are set by the flake. +set -e + +# nixpkgs nodejs bundles an older npm than the repo requires, so install the +# pinned npm into a repo-local prefix instead of the user's global one. +export NPM_CONFIG_PREFIX="$PWD/.nix-npm-global" +export PATH="$NPM_CONFIG_PREFIX/bin:$PATH" +mkdir -p "$NPM_CONFIG_PREFIX" +current_npm="$(npm --version 2>/dev/null || echo 0.0.0)" +if [ "$current_npm" != "$NPM_VERSION" ]; then + echo "Installing npm@$NPM_VERSION into $NPM_CONFIG_PREFIX (was $current_npm)..." + npm install -g "npm@$NPM_VERSION" >/dev/null 2>&1 || + echo "warning: could not install npm@$NPM_VERSION; using $current_npm" >&2 +fi + +# Create the venv that `make dev` and the run targets expect. +if [ ! -d venv ]; then + echo "Creating Python venv (run 'make dev' to install deps)..." + "$PYTHON" -m venv venv +fi +# shellcheck disable=SC1091 +source venv/bin/activate + +set +e + +cat <<'BANNER' + + ACCESS dev shell. + + First-time setup: + make dev # install pinned Python deps into ./venv + editable install + npm install # install frontend deps into ./node_modules + # create a .env with CURRENT_OKTA_USER_EMAIL / OKTA_* / DATABASE_URI (see README) + + Common targets: + make run # backend (uvicorn) + frontend (vite) together + make run-backend # uvicorn --reload + make run-frontend # vite dev server + make test # ruff + mypy + pytest + make pytest-postgres # pytest against a disposable postgres:16 (needs docker) + make db-migrate # alembic upgrade head + +BANNER