diff --git a/docs/CLI.md b/docs/CLI.md index 8976689..6b4110a 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -9,30 +9,121 @@ command is on your `PATH`. quantui --help ``` -The CLI is meant to *complement* the Voilà app — not replace it. Reach -for the CLI when you want to: +The CLI is meant to *complement* the Voilà app. Most commands are +read-only diagnostics against `~/.quantui/` (or whatever +`QUANTUI_LOG_DIR` points at). The exception is **`quantui run app`** +and **`quantui setup`**, which start (or prepare) the student-facing +Voilà interface. +Reach for the CLI when you want to: + +- **launch the app** without remembering Voilà flags or notebook paths - check what the app has been doing without opening a notebook - confirm GPU offload is wired correctly before starting a long run - generate a usage / GPU-speedup report you can share or pin to a tab - script log inspection or analytics into a shell pipeline / cron job -The CLI never touches your live calculations or notebook server. All -commands are read-only against `~/.quantui/` (or whatever -`QUANTUI_LOG_DIR` points at). - --- ## Command reference | Command | What it does | | --- | --- | +| [`quantui run app`](#quantui-run-app) | Start the Voilà student app | +| [`quantui setup`](#quantui-setup) | Write `~/.quantui/app.ipynb` and a `quantui-app` shell shortcut | | [`quantui log tail`](#quantui-log-tail) | Print recent events from `event_log.jsonl` | | [`quantui gpu check`](#quantui-gpu-check) | Probe GPU-offload availability and explain failures | | [`quantui analytics build`](#quantui-analytics-build) | Build an HTML usage dashboard from `perf_log.jsonl` | --- +## `quantui run app` + +Start the student-facing Voilà interface. On first use (or after +`quantui setup`), the CLI writes a thin launcher notebook to +`~/.quantui/app.ipynb` — the same three-line pattern as the repo's +`notebooks/molecule_computations.ipynb`, without requiring a git clone. + +Requires the **`[app]` extra**: + +```bash +pip install 'quantui[app]' +quantui run app +``` + +### Flags + +| Flag | Default | Description | +| --- | --- | --- | +| `--port PORT` | `8867` | TCP port (matches the native `launchers/` scripts) | +| `--open` | off | Open `http://localhost:PORT` in the default browser after startup | +| `--force` | off | Regenerate `~/.quantui/app.ipynb` before starting | + +### Examples + +```bash +# Default — prints the URL, runs until Ctrl-C +quantui run app + +# Open the browser automatically (WSL-aware) +quantui run app --open + +# Custom port +quantui run app --port 8888 +``` + +### Notes + +- Exit code `1` when Voilà is not installed — install `quantui[app]` + and ensure `voila` is on your `PATH`. +- Exit code `1` in **Apptainer + JupyterLab** sessions (NCShare and + similar HPC portals) — use `quantui setup` and launch from JupyterLab + instead; see [`quantui setup`](#ncshare--hpc-jupyterlab). +- Override the config directory with `QUANTUI_HOME` (useful in tests). + +--- + +## `quantui setup` + +One-time (or idempotent) provisioning for users who want a persistent +shell shortcut: + +1. Writes `~/.quantui/app.ipynb` (same as `quantui run app` uses) +2. Writes `~/.local/bin/quantui-app` (or `$XDG_BIN_HOME/quantui-app`) + +```bash +quantui setup +quantui-app # after ~/.local/bin is on PATH +``` + +Pass `--force` to overwrite an existing notebook or script. + +### NCShare / HPC JupyterLab + +On cluster portals that launch QuantUI inside **Apptainer + JupyterLab** +(NCShare is the primary example), the browser proxies only the Jupyter +connection. A standalone Voilà server on port 8867 is **not reachable**. + +When the CLI detects that environment (Apptainer + Jupyter server env +vars), `quantui setup` also writes **`~/QuantUI.ipynb`** — visible in the +JupyterLab file browser — and prints NCShare-specific launch instructions +instead of the usual `quantui run app` guidance. + +Launch QuantUI from JupyterLab: + +1. Open **`~/QuantUI.ipynb`** and click **Render with Voilà** (clean + student view), or +2. Run the one-liner in any notebook: + ```python + from quantui.app import QuantUIApp + QuantUIApp().display() + ``` + +`quantui run app` exits with code `1` in this context and explains the +above — do not use it for browser access on NCShare. + +--- + ## `quantui log tail` Print the last *N* entries from the QuantUI event log @@ -249,6 +340,8 @@ successfully; only the auto-open is best-effort. | Variable | Effect | | --- | --- | +| `QUANTUI_HOME` | Override `~/.quantui/` for the generated launcher notebook (`app.ipynb`) and setup output. | +| `XDG_BIN_HOME` | Override `~/.local/bin` as the destination for the `quantui-app` shell shortcut. | | `QUANTUI_LOG_DIR` | Override the default `~/.quantui/logs/` location. The dashboard's default output (`~/.quantui/dashboard.html`) follows: it lives one level up from the active `QUANTUI_LOG_DIR`. | | `QUANTUI_DISABLE_GPU` | Force CPU mode even when gpu4pyscf is installed. `quantui gpu check` reports this as the reason. Accepted truthy values: `1`, `true`, `True`. | | `QUANTUI_FREQ_PARALLEL` | Opt in to parallel **CPU** workers for the IR-intensity finite-difference loop in frequency calculations (`6N` displaced SCFs). Same effect as the **Parallelize IR intensity displacements** checkbox on the System Settings tab; when this env var is set it overrides the saved setting. Reference SCF and Hessian still use gpu4pyscf when available. Requires ≥4 cores and ≥2 atoms. Off by default. Accepted truthy values: `1`, `true`, `yes`, `on`. | @@ -260,12 +353,21 @@ successfully; only the auto-open is best-effort. ### Verify GPU is wired before a long run ```bash -quantui gpu check && voila notebooks/molecule_computations.ipynb +quantui gpu check && quantui run app ``` -If `gpu check` exits non-zero, the Voilà launch is skipped and the +If `gpu check` exits non-zero, the app launch is skipped and the reason was printed to stderr. +### Launch the app (pip install, no git clone) + +```bash +pip install 'quantui[app]' +quantui run app +# optional one-time shell shortcut: +quantui setup +``` + ### Quick "what happened in my last session?" ```bash diff --git a/quantui/app_launcher.py b/quantui/app_launcher.py new file mode 100644 index 0000000..94f86e6 --- /dev/null +++ b/quantui/app_launcher.py @@ -0,0 +1,375 @@ +"""Voilà app launcher helpers for ``quantui run app`` and ``quantui setup``. + +The student-facing UI is a thin notebook that calls ``QuantUIApp().display()``. +Pip installs do not ship the repo's ``notebooks/`` tree, so the CLI writes an +equivalent launcher notebook to ``~/.quantui/app.ipynb`` on first use (or when +``quantui setup`` runs). + +The module intentionally does not import ``quantui.app`` — only ``voila`` is +required at launch time via the ``[app]`` extra. +""" + +from __future__ import annotations + +import json +import os +import shutil +import stat +import subprocess +import sys +import time +from pathlib import Path +from typing import List, Optional + +DEFAULT_APP_PORT = 8867 +APP_NOTEBOOK_NAME = "app.ipynb" +HOME_LAUNCHER_NOTEBOOK_NAME = "QuantUI.ipynb" +LAUNCHER_SCRIPT_NAME = "quantui-app" + +# Minimal nbformat v4 notebook — mirrors notebooks/molecule_computations.ipynb +# (display cell only; no repo-root sys.path hack needed for installed packages). +_APP_NOTEBOOK: dict = { + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": ["# QuantUI\n"], + }, + { + "cell_type": "code", + "execution_count": None, + "metadata": {"tags": ["remove-input"]}, + "outputs": [], + "source": [ + "from quantui.app import QuantUIApp\n", + "\n", + "QuantUIApp().display()\n", + ], + }, + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3", + }, + "language_info": { + "name": "python", + "pygments_lexer": "ipython3", + }, + }, + "nbformat": 4, + "nbformat_minor": 5, +} + + +def quantui_home() -> Path: + """Return the QuantUI user config directory (``~/.quantui`` by default).""" + override = os.environ.get("QUANTUI_HOME") + if override: + return Path(override).expanduser() + return Path.home() / ".quantui" + + +def app_notebook_path() -> Path: + """Path to the generated Voilà launcher notebook.""" + return quantui_home() / APP_NOTEBOOK_NAME + + +def home_launcher_notebook_path() -> Path: + """Path to a JupyterLab-visible launcher notebook in the user's home.""" + return Path.home() / HOME_LAUNCHER_NOTEBOOK_NAME + + +def is_apptainer_runtime() -> bool: + """Return True when running inside Apptainer/Singularity.""" + for key in ( + "APPTAINER_CONTAINER", + "APPTAINER_NAME", + "SINGULARITY_CONTAINER", + "SINGULARITY_NAME", + ): + if os.environ.get(key): + return True + return Path("/.singularity.d").is_dir() + + +def is_jupyter_server_context() -> bool: + """Return True when a Jupyter server session is active in this environment.""" + for key in ( + "JUPYTER_SERVER_URL", + "JUPYTERHUB_SERVICE_URL", + "JUPYTERHUB_USER", + "JPY_SESSION_NAME", + ): + if os.environ.get(key): + return True + return False + + +def is_hpc_jupyterlab_session() -> bool: + """Heuristic for NCShare-style Apptainer + JupyterLab interactive sessions.""" + return is_apptainer_runtime() and is_jupyter_server_context() + + +def launcher_bin_dir() -> Path: + """Preferred directory for the ``quantui-app`` shell wrapper.""" + xdg = os.environ.get("XDG_BIN_HOME") + if xdg: + return Path(xdg).expanduser() + return Path.home() / ".local" / "bin" + + +def launcher_script_path() -> Path: + return launcher_bin_dir() / LAUNCHER_SCRIPT_NAME + + +def voila_executable() -> Optional[str]: + """Return the ``voila`` executable path, or ``None`` if not installed.""" + return shutil.which("voila") + + +def voila_missing_message() -> str: + return ( + "Voilà is not installed or not on PATH.\n" + "Install the app extra, then retry:\n" + " pip install 'quantui[app]'\n" + " # or, from a dev clone:\n" + " pip install -e '.[app]'" + ) + + +def ensure_app_notebook(*, force: bool = False) -> Path: + """Write ``~/.quantui/app.ipynb`` when missing (or when *force* is True).""" + home = quantui_home() + home.mkdir(parents=True, exist_ok=True) + path = app_notebook_path() + if path.exists() and not force: + return path + path.write_text( + json.dumps(_APP_NOTEBOOK, indent=1) + "\n", + encoding="utf-8", + ) + return path + + +def ensure_home_launcher_notebook(*, force: bool = False) -> Path: + """Write ``~/QuantUI.ipynb`` for JupyterLab file-browser visibility.""" + path = home_launcher_notebook_path() + if path.exists() and not force: + return path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(_APP_NOTEBOOK, indent=1) + "\n", + encoding="utf-8", + ) + return path + + +def hpc_jupyterlab_run_app_message() -> str: + """Explain why ``quantui run app`` is the wrong entry point on NCShare.""" + nb = home_launcher_notebook_path() + return ( + "QuantUI detected an HPC JupyterLab session (Apptainer + Jupyter).\n" + "\n" + "``quantui run app`` starts a standalone Voilà server on a separate\n" + "port. Cluster portals (including NCShare) proxy only the Jupyter\n" + "connection, so that port is not reachable from your browser.\n" + "\n" + "Use one of these instead:\n" + f' 1. Open {nb} in JupyterLab and click "Render with Voilà"\n' + " 2. Run the first cell in any notebook:\n" + " from quantui.app import QuantUIApp\n" + " QuantUIApp().display()\n" + "\n" + "Run ``quantui setup`` once to create ~/QuantUI.ipynb if it is missing." + ) + + +def hpc_jupyterlab_setup_message(home_nb: Path) -> str: + """Post-setup instructions for NCShare-style JupyterLab sessions.""" + return ( + "HPC JupyterLab session detected (Apptainer + Jupyter).\n" + "\n" + "On NCShare and similar clusters, do NOT use ``quantui run app`` — the\n" + "browser cannot reach a second Voilà port.\n" + "\n" + "Launch QuantUI from JupyterLab instead:\n" + f' • Open {home_nb} and click "Render with Voilà" (clean student view)\n' + " • Or run the first cell in any notebook:\n" + " from quantui.app import QuantUIApp\n" + " QuantUIApp().display()\n" + ) + + +def build_voila_argv( + notebook: Path, + *, + port: int = DEFAULT_APP_PORT, + no_browser: bool = True, +) -> List[str]: + """Return a ``voila`` argv list matching the native launchers.""" + argv = [ + "voila", + str(notebook), + f"--port={port}", + "--ServerApp.disable_check_xsrf=True", + ] + if no_browser: + argv.append("--no-browser") + return argv + + +def write_launcher_script(*, force: bool = False) -> Path: + """Write ``~/.local/bin/quantui-app`` (or ``$XDG_BIN_HOME/quantui-app``).""" + bindir = launcher_bin_dir() + bindir.mkdir(parents=True, exist_ok=True) + path = launcher_script_path() + if path.exists() and not force: + return path + content = ( + "#!/usr/bin/env bash\n" + "# QuantUI Voilà launcher — generated by ``quantui setup``.\n" + "set -eu\n" + 'exec quantui run app "$@"\n' + ) + path.write_text(content, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return path + + +def run_voila_app( + *, + port: int = DEFAULT_APP_PORT, + open_browser: bool = False, + force_notebook_refresh: bool = False, +) -> int: + """Provision the launcher notebook and start Voilà. + + Replaces the current process with ``voila`` when *open_browser* is False + (the common case). When *open_browser* is True, Voilà runs in a child + process so the CLI can open the URL after a short bind delay. + """ + if is_hpc_jupyterlab_session(): + print(hpc_jupyterlab_run_app_message(), file=sys.stderr) + return 1 + + voila = voila_executable() + if voila is None: + print(voila_missing_message(), file=sys.stderr) + return 1 + + notebook = ensure_app_notebook(force=force_notebook_refresh) + argv = build_voila_argv(notebook, port=port, no_browser=True) + argv[0] = voila + + url = f"http://localhost:{port}" + print(f"Starting QuantUI at {url}") + print(f"Notebook: {notebook}") + + if not open_browser: + print("Press Ctrl-C to stop.") + os.execvp(voila, argv) + + proc = subprocess.Popen(argv) + time.sleep(4) + _open_url_best_effort(url) + print("Press Ctrl-C to stop.") + try: + return proc.wait() + except KeyboardInterrupt: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + return 130 + + +def _open_url_best_effort(url: str) -> None: + """Open *url* in the user's browser (best-effort, never raises).""" + import subprocess + + if os.environ.get("WSL_DISTRO_NAME") or _is_wsl(): + for tool in ("wslview", "explorer.exe"): + try: + if ( + subprocess.run( + [tool, url], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + == 0 + ): + return + except (FileNotFoundError, OSError): + continue + else: + import webbrowser + + try: + if webbrowser.open(url): + return + except Exception: + pass + print(f"(could not auto-open browser — open {url} manually)", file=sys.stderr) + + +def _is_wsl() -> bool: + try: + with open("/proc/version", encoding="utf-8", errors="ignore") as fh: + return "microsoft" in fh.read().lower() + except OSError: + return False + + +def run_setup(*, force: bool = False) -> int: + """Write the launcher notebook and optional ``quantui-app`` shell script.""" + notebook = ensure_app_notebook(force=force) + script = write_launcher_script(force=force) + bindir = launcher_bin_dir() + hpc = is_hpc_jupyterlab_session() + home_nb: Optional[Path] = None + if hpc: + home_nb = ensure_home_launcher_notebook(force=force) + + print(f"Wrote launcher notebook: {notebook}") + if home_nb is not None: + print(f"Wrote home launcher: {home_nb}") + print(f"Wrote shell shortcut: {script}") + print() + if hpc and home_nb is not None: + print(hpc_jupyterlab_setup_message(home_nb)) + else: + print("Run the app with either:") + print(" quantui run app") + print(f" {script}") + path_entries = os.environ.get("PATH", "").split(os.pathsep) + if str(bindir) not in path_entries: + print() + print(f"Add {bindir} to your PATH to run ``quantui-app`` from anywhere:") + print(f' export PATH="{bindir}:$PATH"') + return 0 + + +__all__ = [ + "DEFAULT_APP_PORT", + "app_notebook_path", + "build_voila_argv", + "ensure_app_notebook", + "ensure_home_launcher_notebook", + "home_launcher_notebook_path", + "hpc_jupyterlab_run_app_message", + "hpc_jupyterlab_setup_message", + "is_apptainer_runtime", + "is_hpc_jupyterlab_session", + "is_jupyter_server_context", + "launcher_script_path", + "quantui_home", + "run_setup", + "run_voila_app", + "voila_executable", + "voila_missing_message", + "write_launcher_script", +] diff --git a/quantui/cli.py b/quantui/cli.py index 8c80726..8e32bcd 100644 --- a/quantui/cli.py +++ b/quantui/cli.py @@ -16,6 +16,11 @@ HTML analytics dashboard from ``perf_log.jsonl``. Default output: ``~/.quantui/dashboard.html``. Pass ``--open`` to automatically open the file in the default browser after writing. +* ``quantui run app [--port PORT] [--open]`` — start the Voilà student + app. Writes ``~/.quantui/app.ipynb`` on first use (requires the + ``[app]`` extra: ``pip install 'quantui[app]'``). +* ``quantui setup [--force]`` — write ``~/.quantui/app.ipynb`` and a + ``quantui-app`` shell shortcut under ``~/.local/bin``. Adding a new subcommand: @@ -218,6 +223,24 @@ def _cmd_analytics_build(args: argparse.Namespace) -> int: return 0 +def _cmd_run_app(args: argparse.Namespace) -> int: + """Start the Voilà student app (lazy-provisions ~/.quantui/app.ipynb).""" + from quantui.app_launcher import run_voila_app + + return run_voila_app( + port=args.port, + open_browser=args.open, + force_notebook_refresh=args.force, + ) + + +def _cmd_setup(args: argparse.Namespace) -> int: + """Write ~/.quantui/app.ipynb and a quantui-app shell shortcut.""" + from quantui.app_launcher import run_setup + + return run_setup(force=args.force) + + def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="quantui", @@ -278,6 +301,42 @@ def _build_parser() -> argparse.ArgumentParser: ) analytics_build.set_defaults(func=_cmd_analytics_build) + setup_parser = sub.add_parser( + "setup", + help="Write ~/.quantui/app.ipynb and a quantui-app shell shortcut.", + ) + setup_parser.add_argument( + "--force", + action="store_true", + help="Overwrite an existing launcher notebook or shell script.", + ) + setup_parser.set_defaults(func=_cmd_setup) + + run_parser = sub.add_parser("run", help="Run QuantUI services.") + run_sub = run_parser.add_subparsers(dest="run_command", required=True) + run_app = run_sub.add_parser( + "app", + help="Start the Voilà student app (pip install 'quantui[app]').", + ) + run_app.add_argument( + "--port", + type=int, + default=8867, + metavar="PORT", + help="TCP port for Voilà (default: 8867, matches native launchers).", + ) + run_app.add_argument( + "--open", + action="store_true", + help="Open http://localhost:PORT in the default browser after startup.", + ) + run_app.add_argument( + "--force", + action="store_true", + help="Regenerate ~/.quantui/app.ipynb before starting.", + ) + run_app.set_defaults(func=_cmd_run_app) + return parser diff --git a/tests/test_cli.py b/tests/test_cli.py index 8a5fd99..32172ee 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -440,6 +440,163 @@ def _fake_open(url, *_args, **_kwargs): assert opened[0].startswith("file:") +class TestAppLauncher: + @pytest.fixture + def isolated_home(self, tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.setenv("QUANTUI_HOME", str(tmp_path / "quantui-home")) + monkeypatch.setenv("XDG_BIN_HOME", str(tmp_path / "bin")) + return tmp_path + + def test_run_app_parser_registered(self): + parser = cli._build_parser() + run_app = parser.parse_args(["run", "app"]) + assert run_app.command == "run" + assert run_app.run_command == "app" + assert run_app.port == 8867 + assert run_app.open is False + + def test_setup_parser_registered(self): + parser = cli._build_parser() + args = parser.parse_args(["setup"]) + assert args.command == "setup" + assert args.force is False + + def test_run_app_missing_voila_returns_one(self, isolated_home, monkeypatch): + from quantui import app_launcher + + monkeypatch.setattr(app_launcher, "voila_executable", lambda: None) + rc, _, err = _capture(["run", "app"]) + assert rc == 1 + assert "Voilà is not installed" in err + + def test_ensure_app_notebook_writes_once(self, isolated_home): + from quantui.app_launcher import app_notebook_path, ensure_app_notebook + + path = ensure_app_notebook() + assert path == app_notebook_path() + assert path.exists() + text = path.read_text(encoding="utf-8") + assert "QuantUIApp" in text + mtime = path.stat().st_mtime_ns + again = ensure_app_notebook() + assert again.stat().st_mtime_ns == mtime + + def test_setup_writes_notebook_and_script(self, isolated_home): + from quantui.app_launcher import ( + app_notebook_path, + launcher_script_path, + run_setup, + ) + + rc, out, _ = _capture(["setup"]) + assert rc == 0 + assert app_notebook_path().exists() + script = launcher_script_path() + assert script.exists() + assert script.read_text(encoding="utf-8").startswith("#!/usr/bin/env bash") + assert "quantui run app" in script.read_text(encoding="utf-8") + assert "Wrote launcher notebook" in out or app_notebook_path().exists() + + # Direct helper also works (covers run_setup print paths). + assert run_setup() == 0 + + def test_build_voila_argv_matches_native_launchers(self, tmp_path): + from quantui.app_launcher import build_voila_argv + + nb = tmp_path / "app.ipynb" + nb.write_text("{}", encoding="utf-8") + argv = build_voila_argv(nb, port=8867) + assert argv[0] == "voila" + assert str(nb) in argv + assert "--port=8867" in argv + assert "--no-browser" in argv + assert "--ServerApp.disable_check_xsrf=True" in argv + + def test_run_app_open_mode_waits_on_child(self, isolated_home, monkeypatch): + from quantui import app_launcher + + monkeypatch.setattr(app_launcher, "voila_executable", lambda: "/usr/bin/voila") + calls: list[list[str]] = [] + + class _Proc: + def wait(self, timeout=None): + return 0 + + def terminate(self): + pass + + def kill(self): + pass + + def _popen(argv): + calls.append(argv) + return _Proc() + + monkeypatch.setattr(app_launcher.subprocess, "Popen", _popen) + monkeypatch.setattr(app_launcher.time, "sleep", lambda _s: None) + monkeypatch.setattr(app_launcher, "_open_url_best_effort", lambda _u: None) + + rc = app_launcher.run_voila_app(open_browser=True) + assert rc == 0 + assert calls + assert calls[0][0] == "/usr/bin/voila" + + def test_is_apptainer_runtime_detects_env(self, monkeypatch): + from quantui.app_launcher import is_apptainer_runtime + + monkeypatch.delenv("APPTAINER_CONTAINER", raising=False) + monkeypatch.delenv("SINGULARITY_CONTAINER", raising=False) + assert is_apptainer_runtime() is False + monkeypatch.setenv("APPTAINER_CONTAINER", "/tmp/quantui.sif") + assert is_apptainer_runtime() is True + + def test_is_jupyter_server_context_detects_env(self, monkeypatch): + from quantui.app_launcher import is_jupyter_server_context + + monkeypatch.delenv("JUPYTER_SERVER_URL", raising=False) + assert is_jupyter_server_context() is False + monkeypatch.setenv("JUPYTER_SERVER_URL", "http://127.0.0.1:8888/") + assert is_jupyter_server_context() is True + + def test_is_hpc_jupyterlab_session_requires_both(self, monkeypatch): + from quantui.app_launcher import is_hpc_jupyterlab_session + + monkeypatch.delenv("APPTAINER_CONTAINER", raising=False) + monkeypatch.delenv("JUPYTER_SERVER_URL", raising=False) + assert is_hpc_jupyterlab_session() is False + monkeypatch.setenv("APPTAINER_CONTAINER", "/tmp/quantui.sif") + assert is_hpc_jupyterlab_session() is False + monkeypatch.setenv("JUPYTER_SERVER_URL", "http://127.0.0.1:8888/") + assert is_hpc_jupyterlab_session() is True + + def test_setup_writes_home_notebook_in_hpc_context( + self, isolated_home, monkeypatch + ): + from quantui.app_launcher import home_launcher_notebook_path + + monkeypatch.setenv("APPTAINER_CONTAINER", "/tmp/quantui.sif") + monkeypatch.setenv("JUPYTER_SERVER_URL", "http://127.0.0.1:8888/") + rc, out, _ = _capture(["setup"]) + assert rc == 0 + home_nb = home_launcher_notebook_path() + assert home_nb.exists() + assert "QuantUIApp" in home_nb.read_text(encoding="utf-8") + assert "Render with Voilà" in out + assert "do NOT use ``quantui run app``" in out + + def test_run_app_fails_fast_in_hpc_context(self, isolated_home, monkeypatch): + from quantui import app_launcher + + monkeypatch.setenv("APPTAINER_CONTAINER", "/tmp/quantui.sif") + monkeypatch.setenv("JUPYTER_SERVER_URL", "http://127.0.0.1:8888/") + monkeypatch.setattr(app_launcher, "voila_executable", lambda: "/usr/bin/voila") + rc, _, err = _capture(["run", "app"]) + assert rc == 1 + assert "HPC JupyterLab session" in err + assert "Render with Voilà" in err + + class TestCliAvoidsGuiStackImport: """M13 audit fix: ``import quantui.cli`` must not pull in ipywidgets.