Skip to content

init-dev.sh fails with "No virtual environment found" during copier generation #2

Description

@abosio

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:

  1. 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).

  2. 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.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions