Summary
Generating a project (via scaf / copier copy / test-scaf.sh) fails during the post-generation _tasks step. python tasks.py runs scripts/init-dev.sh, which aborts at the uv pip install step with:
error: No virtual environment found; run `uv venv` to create an environment, or pass `--system` to install into a non-virtual environment
This propagates up as a non-zero exit and copier reports the whole generation as failed.
Reproduction
./test-scaf.sh -t ../django-template -o ../testscaf/
Observed tail:
Resolved 35 packages in 4.29s
Resolved 59 packages in 11.07s
Resolved 58 packages in 587ms
Resolved 57 packages in 414ms
error: No virtual environment found; run `uv venv` to create an environment, or pass `--system` to install into a non-virtual environment
...
subprocess.CalledProcessError: Command '['bash', './scripts/init-dev.sh']' returned non-zero exit status 2.
...
subprocess.CalledProcessError: Command 'python tasks.py' returned non-zero exit status 1.
Root cause
scripts/init-dev.sh runs:
uv pip compile backend/requirements/base.in > backend/requirements/base.txt
# ... (four compile steps — compile does NOT require a venv)
uv pip install -r backend/requirements/local.txt -r backend/requirements/tests.txt
uv pip install requires an active virtual environment. This is why the four uv pip compile steps succeed (they emit the four Resolved N packages lines) but the install fails immediately after.
Note this is not a "uv is not installed" problem — uv is present and working (it just ran four compiles). scaf's install.sh already guarantees the uv binary via install_uv(). The missing piece is a virtual environment to install into, which nothing in the generation flow creates at that point.
The venv is not created by init-dev.sh. It is created by the flake.nix dev shell shellHook:
if [ ! -d .venv ]; then
uv venv .venv --python python__PYTHON_VERSION__
. .venv/bin/activate
else
. .venv/bin/activate
fi
So the current design assumes init-dev.sh runs inside the nix dev shell (via direnv / .envrc), where .venv already exists and is activated. But copier runs python tasks.py as a post-generation task in a plain subprocess — not inside that shell. So .venv doesn't exist, $VIRTUAL_ENV is unset, and uv pip install fails. Because init-dev.sh uses set -euo pipefail, it exits 2, surfacing as the copier task failure above.
(The pyenv: pre-commit: command not found line seen earlier is a separate, benign issue — handled by || echo "pre-commit install failed; continuing".)
Why the venv responsibility is split
Venv creation (flake.nix) and dependency install (init-dev.sh) live in different files, coupled only by the assumption that the nix shell ran first. That assumption does not hold in copier's task subprocess.
This also matters because Nix is an optional path for scaf — users can develop without it (see getscaf/scaf#310 re: the non-Nix development path). Any solution that relies on the flake.nix shellHook to create the venv is fragile: it breaks both in copier's task subprocess and for anyone not using Nix at all.
Why a "just create a venv in init-dev.sh" guard is NOT sufficient
The tempting quick fix is to guard the install in init-dev.sh:
if [ -z "${VIRTUAL_ENV:-}" ]; then
[ ! -d .venv ] && uv venv .venv
. .venv/bin/activate
fi
This stops the crash, but it quietly breaks the Nix path, for two reasons:
-
Task ordering (copier.yml):
_tasks:
- python tasks.py # task 1 — runs init-dev.sh (the install)
- bash scripts/update-python-version.sh # task 2 — substitutes the pinned Python
The pinned Python version doesn't exist yet when init-dev.sh runs. update-python-version.sh (task 2) is what rewrites flake.nix's uv venv .venv --python python__PYTHON_VERSION__ placeholder into a concrete version (e.g. python3.14). So at install time (task 1) we can't even know which interpreter the flake wants — a bare uv venv .venv would grab whatever Python uv picks (e.g. the system 3.12).
-
The flake's guard adopts whatever .venv already exists. flake.nix only builds the venv when it's absent (if [ ! -d .venv ]). If generation already created a .venv with the wrong Python, a Nix user entering the generated project's dev shell takes the else branch and silently activates the mismatched venv instead of building one with the pinned interpreter. Crash fixed, version pinning defeated.
Suggested fix: move the install out of generation time
Follow the pattern sfu-fullstack-template already uses successfully: do not run uv pip install during copier generation at all.
- Generation-time task does compile only (
uv pip compile — needs no venv). In sfu-fullstack-template, the copier tasks.py runs make compile and nothing more.
uv pip install moves to wherever the venv is created, coupling uv venv + activate + install in a single block:
- the Nix
shellHook for Nix users (pinned Python), and/or
- a task-runner target (
make / just / task) for the non-Nix path.
In sfu-fullstack-template's Makefile this coupling looks like:
uv venv ./venv; \
. ./venv/bin/activate; \
uv pip install -r requirements/production.txt -r requirements/tests.txt;
This way the venv is created exactly once, by the tool that knows which Python to use — no mismatch on either path, and generation never touches a venv.
Concretely for this repo
- Remove the
uv pip install ... line from scripts/init-dev.sh (keep the uv pip compile steps, which are venv-free).
- Ensure the install happens in the dev environment instead:
- Nix users get it via the
flake.nix shellHook (uncomment/enable the install after . .venv/bin/activate, using the pinned Python).
- Non-Nix users get it via a task-runner target that creates + activates the venv and then installs.
Summary
Generating a project (via
scaf/copier copy/test-scaf.sh) fails during the post-generation_tasksstep.python tasks.pyrunsscripts/init-dev.sh, which aborts at theuv pip installstep with:This propagates up as a non-zero exit and copier reports the whole generation as failed.
Reproduction
Observed tail:
Root cause
scripts/init-dev.shruns:uv pip installrequires an active virtual environment. This is why the fouruv pip compilesteps succeed (they emit the fourResolved N packageslines) but the install fails immediately after.Note this is not a "uv is not installed" problem —
uvis present and working (it just ran four compiles).scaf'sinstall.shalready guarantees theuvbinary viainstall_uv(). The missing piece is a virtual environment to install into, which nothing in the generation flow creates at that point.The venv is not created by
init-dev.sh. It is created by theflake.nixdev shellshellHook:So the current design assumes
init-dev.shruns inside the nix dev shell (via direnv /.envrc), where.venvalready exists and is activated. But copier runspython tasks.pyas a post-generation task in a plain subprocess — not inside that shell. So.venvdoesn't exist,$VIRTUAL_ENVis unset, anduv pip installfails. Becauseinit-dev.shusesset -euo pipefail, it exits 2, surfacing as the copier task failure above.(The
pyenv: pre-commit: command not foundline seen earlier is a separate, benign issue — handled by|| echo "pre-commit install failed; continuing".)Why the venv responsibility is split
Venv creation (
flake.nix) and dependency install (init-dev.sh) live in different files, coupled only by the assumption that the nix shell ran first. That assumption does not hold in copier's task subprocess.This also matters because Nix is an optional path for scaf — users can develop without it (see getscaf/scaf#310 re: the non-Nix development path). Any solution that relies on the
flake.nixshellHookto create the venv is fragile: it breaks both in copier's task subprocess and for anyone not using Nix at all.Why a "just create a venv in init-dev.sh" guard is NOT sufficient
The tempting quick fix is to guard the install in
init-dev.sh:This stops the crash, but it quietly breaks the Nix path, for two reasons:
Task ordering (
copier.yml):The pinned Python version doesn't exist yet when
init-dev.shruns.update-python-version.sh(task 2) is what rewritesflake.nix'suv venv .venv --python python__PYTHON_VERSION__placeholder into a concrete version (e.g.python3.14). So at install time (task 1) we can't even know which interpreter the flake wants — a bareuv venv .venvwould grab whatever Pythonuvpicks (e.g. the system 3.12).The flake's guard adopts whatever
.venvalready exists.flake.nixonly builds the venv when it's absent (if [ ! -d .venv ]). If generation already created a.venvwith the wrong Python, a Nix user entering the generated project's dev shell takes theelsebranch and silently activates the mismatched venv instead of building one with the pinned interpreter. Crash fixed, version pinning defeated.Suggested fix: move the install out of generation time
Follow the pattern
sfu-fullstack-templatealready uses successfully: do not runuv pip installduring copier generation at all.uv pip compile— needs no venv). Insfu-fullstack-template, the copiertasks.pyrunsmake compileand nothing more.uv pip installmoves to wherever the venv is created, couplinguv venv+ activate + install in a single block:shellHookfor Nix users (pinned Python), and/ormake/just/task) for the non-Nix path.In
sfu-fullstack-template'sMakefilethis coupling looks like:This way the venv is created exactly once, by the tool that knows which Python to use — no mismatch on either path, and generation never touches a venv.
Concretely for this repo
uv pip install ...line fromscripts/init-dev.sh(keep theuv pip compilesteps, which are venv-free).flake.nixshellHook(uncomment/enable the install after. .venv/bin/activate, using the pinned Python).