QuantumLearning/scripts/app.sh

480 lines
11 KiB
Bash
Raw Normal View History

2026-04-15 13:07:33 +00:00
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
LOG_DIR="$ROOT_DIR/.logs"
LOG_FILE="$LOG_DIR/jupyterlab.log"
usage() {
cat <<'EOF'
Usage: bash ./scripts/app.sh <command> [options]
Consumer-facing control surface for QuantumLearning.
Commands:
bootstrap [--skip-browser] [--run-validation]
Create or refresh the repo-local environment.
start [--port PORT] [--open] [--foreground] [--restart]
Start the repo-local Jupyter server.
Default mode is background service mode.
stop
Stop the repo-local Jupyter server for this project.
restart [--port PORT] [--open] [--foreground]
Restart the repo-local Jupyter server.
status
Print the current project and Jupyter status.
url
Print the current Jupyter URL for this project.
open
Open the current Jupyter URL in the default browser.
logs [-f|--follow]
Show the Jupyter log file. Use -f to follow live output.
validate [--quick|--standard|--full]
Run the validation suite.
Examples:
bash ./scripts/app.sh bootstrap
bash ./scripts/app.sh start --open
bash ./scripts/app.sh restart
bash ./scripts/app.sh status
bash ./scripts/app.sh stop
bash ./scripts/app.sh validate --quick
EOF
}
die() {
echo "$*" >&2
exit 1
}
ensure_dirs() {
mkdir -p \
"$ROOT_DIR/.cache/matplotlib" \
"$ROOT_DIR/.ipython" \
"$ROOT_DIR/.jupyter_config" \
"$ROOT_DIR/.jupyter_data" \
"$ROOT_DIR/.jupyter_runtime" \
"$LOG_DIR"
}
ensure_venv() {
if [[ ! -x "$ROOT_DIR/.venv/bin/python" ]]; then
die "Missing repo-local .venv. Run: bash ./scripts/app.sh bootstrap"
fi
}
set_jupyter_env() {
export PATH="$ROOT_DIR/.venv/bin:/usr/bin:/bin:/usr/sbin:/sbin"
export JUPYTER_CONFIG_DIR="$ROOT_DIR/.jupyter_config"
export JUPYTER_DATA_DIR="$ROOT_DIR/.jupyter_data"
export JUPYTER_RUNTIME_DIR="$ROOT_DIR/.jupyter_runtime"
export IPYTHONDIR="$ROOT_DIR/.ipython"
export MPLCONFIGDIR="$ROOT_DIR/.cache/matplotlib"
}
ensure_kernel() {
local kernel_dir="$ROOT_DIR/.venv/share/jupyter/kernels/quantum-learning"
if [[ ! -f "$kernel_dir/kernel.json" ]]; then
"$ROOT_DIR/.venv/bin/python" -m ipykernel install \
--sys-prefix \
--name quantum-learning \
--display-name "QuantumLearning (.venv)" \
--env IPYTHONDIR "$IPYTHONDIR" \
--env MPLCONFIGDIR "$MPLCONFIGDIR"
fi
}
server_json_for_root() {
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$ROOT_DIR"
from __future__ import annotations
import json
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
runtime_dir = root / ".jupyter_runtime"
matches = []
for path in runtime_dir.glob("jpserver-*.json"):
try:
payload = json.loads(path.read_text())
except Exception:
continue
if Path(payload.get("root_dir", "")).resolve() == root:
matches.append((path.stat().st_mtime, path))
if matches:
print(max(matches)[1])
PY
}
server_json_for_pid() {
local pid="$1"
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$ROOT_DIR" "$pid"
from __future__ import annotations
import json
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
target_pid = int(sys.argv[2])
runtime_dir = root / ".jupyter_runtime"
matches = []
for path in runtime_dir.glob("jpserver-*.json"):
try:
payload = json.loads(path.read_text())
except Exception:
continue
if Path(payload.get("root_dir", "")).resolve() == root and int(payload.get("pid", -1)) == target_pid:
matches.append((path.stat().st_mtime, path))
if matches:
print(max(matches)[1])
PY
}
server_field() {
local json_path="$1"
local field_name="$2"
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$json_path" "$field_name"
from __future__ import annotations
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
field_name = sys.argv[2]
payload = json.loads(path.read_text())
value = payload[field_name]
print(value)
PY
}
server_url_from_json() {
local json_path="$1"
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$json_path"
from __future__ import annotations
import json
import sys
from pathlib import Path
path = Path(sys.argv[1])
payload = json.loads(path.read_text())
print(f"{payload['url']}lab/tree/notebooks/COURSE_BLUEPRINT.ipynb?token={payload['token']}")
PY
}
cleanup_stale_runtime_files() {
"$ROOT_DIR/.venv/bin/python" - <<'PY' "$ROOT_DIR" "${1:-0}"
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
verbose = sys.argv[2] == "1"
runtime_dir = root / ".jupyter_runtime"
removed = []
for path in sorted(runtime_dir.glob("jpserver-*.json")):
try:
payload = json.loads(path.read_text())
except Exception:
continue
if Path(payload.get("root_dir", "")).resolve() != root:
continue
pid = int(payload.get("pid", -1))
alive = False
if pid > 0:
try:
os.kill(pid, 0)
except ProcessLookupError:
alive = False
except PermissionError:
alive = True
else:
alive = True
if alive:
continue
for candidate in (path, path.with_name(f"{path.stem}-open.html")):
if candidate.exists():
candidate.unlink()
removed.append(candidate)
if verbose:
for path in removed:
print(path)
PY
}
have_running_server() {
local json_path
cleanup_stale_runtime_files >/dev/null
json_path="$(server_json_for_root || true)"
if [[ -z "$json_path" ]]; then
return 1
fi
local pid
pid="$(server_field "$json_path" "pid")"
kill -0 "$pid" >/dev/null 2>&1
}
print_running_server() {
local json_path url pid
cleanup_stale_runtime_files >/dev/null
json_path="$(server_json_for_root || true)"
if [[ -z "$json_path" ]]; then
die "No repo-local Jupyter runtime file found. Run: bash ./scripts/app.sh start"
fi
url="$(server_url_from_json "$json_path")"
pid="$(server_field "$json_path" "pid")"
echo "Jupyter URL: $url"
echo "PID: $pid"
echo "Runtime file: $json_path"
echo "Log file: $LOG_FILE"
}
stop_server() {
ensure_venv
ensure_dirs
local json_path pid
cleanup_stale_runtime_files >/dev/null
json_path="$(server_json_for_root || true)"
if [[ -z "$json_path" ]]; then
echo "No repo-local Jupyter runtime file found."
return 0
fi
pid="$(server_field "$json_path" "pid")"
if ! kill -0 "$pid" >/dev/null 2>&1; then
local removed_paths
removed_paths="$(cleanup_stale_runtime_files 1 || true)"
echo "Jupyter runtime file exists, but the process is not running."
echo "Runtime file: $json_path"
if [[ -n "$removed_paths" ]]; then
echo "Removed stale runtime files:"
echo "$removed_paths"
fi
return 0
fi
kill -TERM "$pid"
for _ in $(seq 1 30); do
if ! kill -0 "$pid" >/dev/null 2>&1; then
cleanup_stale_runtime_files >/dev/null
echo "Stopped Jupyter (pid $pid)."
return 0
fi
sleep 1
done
die "Jupyter did not stop cleanly after 30 seconds. Inspect: $LOG_FILE"
}
start_server() {
local port="$1"
local foreground="$2"
local open_browser="$3"
local restart="$4"
ensure_venv
ensure_dirs
set_jupyter_env
ensure_kernel
cleanup_stale_runtime_files >/dev/null
if have_running_server; then
if [[ "$restart" == "1" ]]; then
stop_server
else
echo "Jupyter is already running for this project."
print_running_server
if [[ "$open_browser" == "1" ]]; then
local existing_url
existing_url="$(server_url_from_json "$(server_json_for_root)")"
open "$existing_url" >/dev/null 2>&1 || true
fi
return 0
fi
fi
if [[ "$foreground" == "1" ]]; then
exec "$ROOT_DIR/.venv/bin/jupyter" lab \
--no-browser \
--ip=127.0.0.1 \
--port="$port"
fi
: > "$LOG_FILE"
nohup "$ROOT_DIR/.venv/bin/jupyter" lab \
--no-browser \
--ip=127.0.0.1 \
--port="$port" \
< /dev/null \
>>"$LOG_FILE" 2>&1 &
local launch_pid=$!
local json_path=""
for _ in $(seq 1 60); do
if ! kill -0 "$launch_pid" >/dev/null 2>&1; then
echo "Jupyter exited during startup. Recent log output:" >&2
tail -n 40 "$LOG_FILE" >&2 || true
exit 1
fi
json_path="$(server_json_for_pid "$launch_pid" || true)"
if [[ -n "$json_path" ]]; then
local url
url="$(server_url_from_json "$json_path")"
echo "Jupyter started."
echo "URL: $url"
echo "PID: $launch_pid"
echo "Log: $LOG_FILE"
if [[ "$open_browser" == "1" ]]; then
open "$url" >/dev/null 2>&1 || true
fi
return 0
fi
sleep 1
done
die "Timed out waiting for Jupyter startup. Inspect: $LOG_FILE"
}
show_logs() {
ensure_dirs
if [[ ! -f "$LOG_FILE" ]]; then
echo "No Jupyter log file exists yet."
return 0
fi
if [[ "${1:-}" == "--follow" || "${1:-}" == "-f" ]]; then
tail -f "$LOG_FILE"
else
tail -n 80 "$LOG_FILE"
fi
}
validate_command() {
bash "$ROOT_DIR/scripts/run_validation.sh" "$@"
}
start_command() {
local port="8888"
local foreground="0"
local open_browser="0"
local restart="0"
while [[ $# -gt 0 ]]; do
case "$1" in
--port)
[[ $# -ge 2 ]] || die "--port requires a value"
port="$2"
shift 2
;;
--open)
open_browser="1"
shift
;;
--foreground)
foreground="1"
shift
;;
--restart)
restart="1"
shift
;;
-h|--help)
cat <<'EOF'
Usage: bash ./scripts/app.sh start [--port PORT] [--open] [--foreground] [--restart]
EOF
return 0
;;
*)
die "Unknown start option: $1"
;;
esac
done
[[ "$port" =~ ^[0-9]+$ ]] || die "Port must be an integer, got: $port"
start_server "$port" "$foreground" "$open_browser" "$restart"
}
restart_command() {
start_command --restart "$@"
}
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
COMMAND="$1"
shift
case "$COMMAND" in
bootstrap)
exec bash "$ROOT_DIR/scripts/bootstrap_mac.sh" "$@"
;;
start)
start_command "$@"
;;
stop)
stop_server
;;
restart)
restart_command "$@"
;;
status)
exec bash "$ROOT_DIR/scripts/project_status.sh"
;;
url)
ensure_venv
cleanup_stale_runtime_files >/dev/null
json_path="$(server_json_for_root || true)"
[[ -n "$json_path" ]] || die "No repo-local Jupyter runtime file found. Run: bash ./scripts/app.sh start"
server_url_from_json "$json_path"
;;
open)
ensure_venv
cleanup_stale_runtime_files >/dev/null
json_path="$(server_json_for_root || true)"
[[ -n "$json_path" ]] || die "No repo-local Jupyter runtime file found. Run: bash ./scripts/app.sh start"
url="$(server_url_from_json "$json_path")"
open "$url"
echo "Opened: $url"
;;
logs)
show_logs "${1:-}"
;;
validate)
validate_command "$@"
;;
-h|--help|help)
usage
;;
*)
usage >&2
die "Unknown command: $COMMAND"
;;
esac