diff --git a/scripts/course_lib.sh b/scripts/course_lib.sh index 2c26754d..9ea3f682 100644 --- a/scripts/course_lib.sh +++ b/scripts/course_lib.sh @@ -29,6 +29,12 @@ readonly COURSE_GATES _have() { command -v "$1" > /dev/null 2>&1; } +# `command -v` is true for a dead pipx shim: the file is there and executable, +# and only running it shows that the interpreter it points at is gone. That is +# what `jupyter` is on this machine, and it turned a clean "blocked: needs +# jupyter" into a confusing exec failure. Probe by running, not by looking. +_works() { _have "$1" && "$1" --version > /dev/null 2>&1; } + # Report "blocked" with the reason, which is the whole point of the status # table: "needs X" is useful, "failed" is not. _blocked() { @@ -43,6 +49,17 @@ _no_code() { _runnable() { printf '%s\n' "$1"; exit 0; } +# What the course tree looks like to git. Compared before and after a run, +# because executing coursework has twice silently overwritten a submitted +# deliverable -- TRAK's outputs/output.png and SPD's results_*.png -- and +# neither was noticed until a much later `git status`. Gitignored build output +# does not show up here, which is why every runner writes into build/. +# numstat is included so that re-writing an already-modified file still counts. +_tree_state() { + git -C "$COURSE_ROOT" status --porcelain -- "$COURSE_ROOT" 2>/dev/null || true + git -C "$COURSE_ROOT" diff --numstat -- "$COURSE_ROOT" 2>/dev/null || true +} + # First tool present wins; otherwise blocked naming all the candidates. _require_any() { local label="$1"; shift @@ -120,7 +137,17 @@ course_main() { printf 'usage: %s [--probe]\n' "$(basename "$0")" printf ' --probe report runnable/blocked/no-code without running\n' ;; - "") _run ;; + "") + local before after + before="$(_tree_state)" + _run + after="$(_tree_state)" + if [[ "$before" != "$after" ]]; then + printf 'FAILED: the run changed files under %s\n' "$COURSE_ROOT" >&2 + diff <(printf '%s\n' "$before") <(printf '%s\n' "$after") >&2 || true + return 1 + fi + ;; *) printf 'unknown option: %s\n' "$1" >&2; return 1 ;; esac } diff --git a/scripts/course_probe.sh b/scripts/course_probe.sh index e399b149..5dc43347 100644 --- a/scripts/course_probe.sh +++ b/scripts/course_probe.sh @@ -52,7 +52,7 @@ _probe() { _runnable "compiles each standalone .cpp" ;; notebook) - _have jupyter || _blocked "needs jupyter" + _works jupyter || _blocked "needs jupyter" # Being on PATH is not the same as working: this machine has a # jupyter shim whose pipx venv interpreter is gone, so it fails # with "bad interpreter". Probe by actually invoking it. diff --git a/scripts/course_run.sh b/scripts/course_run.sh index ee6b39fd..93a96d19 100644 --- a/scripts/course_run.sh +++ b/scripts/course_run.sh @@ -181,11 +181,20 @@ _run() { printf 'binaries in %s/build\n' "$COURSE_ROOT" ;; notebook) - _have jupyter || _blocked "needs jupyter" - local nb + _works jupyter || _blocked "needs jupyter" + # NOT --inplace: all four notebooks in this repo are tracked WITH + # their outputs, so executing in place rewrites a deliverable. + # --output-dir sends the executed copy to a temp dir instead. + # It does NOT redirect what the notebook's own code writes -- the + # kernel's cwd is still the notebook's directory -- so the + # before/after tree check in course_main is what actually catches + # a notebook that saves a figure beside itself. + local nb nbtmp + nbtmp="$(mktemp -d)" while IFS= read -r nb; do - jupyter nbconvert --to notebook --execute --inplace "$nb" + jupyter nbconvert --to notebook --execute --output-dir "$nbtmp" "$nb" done < <(find "$COURSE_ROOT" -name '*.ipynb' -not -path '*/.ipynb_checkpoints/*' | sort) + rm -rf "$nbtmp" ;; matlab) local tool