Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

150 changes: 150 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
@@ -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}
'';
};
}
);
}
44 changes: 44 additions & 0 deletions nix/dev-shell-hook.sh
Original file line number Diff line number Diff line change
@@ -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