diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index a546f12..299d71a 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,5 +1,11 @@ name: Deploy GitHub Pages +# Publishes the MkDocs site plus the browser QuantUI Viewer (Voici + Pyodide, +# M-VIEWER) at /viewer/app/; /viewer/ is its docs page +# (docs/viewer.md). The viewer is built from the checked-out commit: on a +# release that is the release tag, so the hosted viewer matches the +# installers attached to that release. + on: push: branches: [main] @@ -8,7 +14,10 @@ on: - "mkdocs.yml" - "requirements-docs.txt" - "CHANGELOG.md" + - "packaging/viewer/**" - ".github/workflows/pages.yml" + release: + types: [published] workflow_dispatch: permissions: @@ -41,6 +50,21 @@ jobs: - name: Build documentation run: mkdocs build --strict + # Own venv: the viewer build pulls in QuantUI, PySCF and Voici, which + # must not mix with the docs toolchain above. + - name: Build the browser viewer into site/viewer/app/ + run: | + python -m venv "$RUNNER_TEMP/viewer-env" + source "$RUNNER_TEMP/viewer-env/bin/activate" + python -m pip install --upgrade pip build + python -m build --wheel --outdir "$RUNNER_TEMP/dist" + pip install ".[pyscf,ase]" + python packaging/viewer/browser/make_samples.py "$RUNNER_TEMP/samples" + packaging/viewer/browser/build.sh "$RUNNER_TEMP"/dist/quantui-*.whl \ + "$RUNNER_TEMP/samples" site/viewer/app + test -f site/viewer/app/voici/render/viewer.html + test -f site/viewer/index.html # the docs page survived + - name: Upload Pages artifact uses: actions/upload-pages-artifact@v5 with: diff --git a/.github/workflows/viewer-builds.yml b/.github/workflows/viewer-builds.yml new file mode 100644 index 0000000..7f6c18c --- /dev/null +++ b/.github/workflows/viewer-builds.yml @@ -0,0 +1,164 @@ +name: Viewer builds + +# Lightweight QuantUI Viewer (History + Analysis only, no quantum engine) in +# its two delivery forms — see packaging/viewer/README.md: +# +# installer conda-constructor installers (Windows .exe, macOS .pkg, Linux +# .sh), UNSIGNED, each silently installed and smoke-tested. +# browser static Voici + Pyodide site (runs entirely in the browser). +# +# Runs only when the viewer packaging changes, on demand, or when a GitHub +# Release is published — never on the normal PR path, so it cannot slow or +# break regular CI. On a release, the three installers are attached to it +# (the browser build is published by pages.yml instead). + +on: + push: + paths: + - "packaging/viewer/**" + - ".github/workflows/viewer-builds.yml" + release: + types: [published] + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + wheel: + name: Build QuantUI wheel + runs-on: ubuntu-latest + outputs: + version: ${{ steps.ver.outputs.version }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.11" + - run: python -m pip install --upgrade pip build + - run: python -m build --wheel + - id: ver + run: | + v=$(ls dist/quantui-*.whl | sed -E 's/.*quantui-([^-]+)-.*/\1/') + echo "version=$v" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v7 + with: + name: wheel + path: dist/*.whl + if-no-files-found: error + + installer: + name: Installer (${{ matrix.os }}) + needs: wheel + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-14] + defaults: + run: + shell: bash -el {0} + env: + QUANTUI_VERSION: ${{ needs.wheel.outputs.version }} + steps: + - uses: actions/checkout@v7 + - uses: actions/download-artifact@v8 + with: + name: wheel + path: packaging/viewer/installer + - uses: conda-incubator/setup-miniconda@v3 + with: + miniforge-version: latest + activate-environment: build + python-version: "3.11" + - name: Install constructor + run: conda install -y -c conda-forge constructor + - name: Build installer + run: | + cp LICENSE packaging/viewer/installer/ + constructor packaging/viewer/installer --output-dir out + ls -la out + + - name: Smoke test (Linux) + if: runner.os == 'Linux' + run: | + bash out/*.sh -b -p "$RUNNER_TEMP/qv" + "$RUNNER_TEMP/qv/bin/quantui" view --help + "$RUNNER_TEMP/qv/bin/python" -c "from quantui.app import QuantUIApp; QuantUIApp(viewer=True); print('viewer ok')" + test -f "$HOME/.local/share/applications/quantui-viewer.desktop" + - name: Smoke test (macOS) + if: runner.os == 'macOS' + run: | + installer -pkg out/*.pkg -target CurrentUserHomeDirectory + q=$(find "$HOME" -maxdepth 4 -path "*/bin/quantui" -type f | head -1) + echo "installed at: $q" + "$q" view --help + "$(dirname "$q")/python" -c "from quantui.app import QuantUIApp; QuantUIApp(viewer=True); print('viewer ok')" + test -x "$HOME/Applications/QuantUI Viewer.app/Contents/MacOS/QuantUI Viewer" + - name: Smoke test (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $exe = (Get-ChildItem out\*.exe)[0].FullName + Start-Process -Wait -FilePath $exe -ArgumentList '/S', '/InstallationType=JustMe', '/D=C:\qv' + & C:\qv\Scripts\quantui.exe view --help + & C:\qv\python.exe -c "from quantui.app import QuantUIApp; QuantUIApp(viewer=True); print('viewer ok')" + if ($LASTEXITCODE -ne 0) { exit 1 } + Get-Content C:\qv\post_install.log -ErrorAction SilentlyContinue + if (-not (Test-Path ([Environment]::GetFolderPath('Programs') + '\QuantUI Viewer.lnk'))) { throw 'Start-menu shortcut missing' } + + - uses: actions/upload-artifact@v7 + with: + name: installer-${{ runner.os }} + path: out/QuantUI-Viewer-* + if-no-files-found: error + + browser: + name: Browser build (Voici + Pyodide) + # On a release pages.yml builds and publishes it; no need to twice. + if: github.event_name != 'release' + needs: wheel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.11" + - uses: actions/download-artifact@v8 + with: + name: wheel + path: dist + - name: Generate demo results (needs PySCF) + run: | + python -m pip install --upgrade pip + pip install -e ".[pyscf,ase]" + python packaging/viewer/browser/make_samples.py samples + - name: Build static site + run: packaging/viewer/browser/build.sh dist/quantui-*.whl samples site + - uses: actions/upload-artifact@v7 + with: + name: browser-site + path: site + if-no-files-found: error + + attach: + name: Attach installers to the release + if: github.event_name == 'release' + needs: installer + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v8 + with: + pattern: installer-* + path: installers + merge-multiple: true + - name: Upload to ${{ github.event.release.tag_name }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name }} + run: | + ls -la installers + gh release upload "$TAG" installers/QuantUI-Viewer-* \ + --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c674632..23fd7f7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,8 +10,10 @@ repos: exclude: "notebooks/" # notebooks manage their own line endings - id: check-yaml # mkdocs.yml uses Material's !!python/name: tags for emoji config; - # the stock YAML parser rejects those without --unsafe. - exclude: ^mkdocs\.yml$ + # the stock YAML parser rejects those without --unsafe. construct.yaml + # is a conda-constructor template (jinja + per-OS "# [win]" selector + # lines that repeat keys), not plain YAML. + exclude: ^(mkdocs\.yml|packaging/viewer/installer/construct\.yaml)$ - id: check-json exclude: "notebooks/" # notebook JSON is managed by Jupyter - id: check-merge-conflict diff --git a/CHANGELOG.md b/CHANGELOG.md index e2e621a..5d5f0ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Added + +- **Viewer mode (`quantui view [FOLDER]`)** — a lightweight QuantUI with only + the History and Analysis tabs, pointed at a results folder, needing no quantum + engine. `QuantUIApp(viewer=True, results_dir=...)` adds a results-folder bar + above the tabs; `quantui view` starts Voilà on a free `127.0.0.1` port and + opens the browser. Packaging for two delivery routes lives in + `packaging/viewer/` (unsigned conda-constructor desktop installers and a + Voici/Pyodide browser build), built by the new `viewer-builds` workflow. +- **QuantUI Viewer in the docs** — new *QuantUI Viewer* page + (`/viewer/`) with a link to the browser Viewer, which the Pages workflow now + builds and publishes at `/viewer/app/` on each release and docs change. + Publishing a release also attaches the three Viewer installers to it. + +### Fixed + +- History → "View Results" / "View Analysis" navigated by fixed tab index, so + with the Cluster Jobs tab visible they landed one tab early. They now look + the tab up by name. +- Background renders (vibrational animation, isosurface, export) and the + activity-light timer fall back to running inline when threads are + unavailable instead of leaving the panel stuck. + ## [0.10.0] - 2026-10-03 ### Added diff --git a/docs/viewer.md b/docs/viewer.md new file mode 100644 index 0000000..672b217 --- /dev/null +++ b/docs/viewer.md @@ -0,0 +1,57 @@ +# QuantUI Viewer + +The Viewer is a lightweight QuantUI for looking at results you already have. +It has only the **History** and **Analysis** tabs: energies, orbital diagrams, +IR / Raman / UV-Vis spectra, optimization trajectories, vibrational modes and +populations. It cannot run calculations. + +There are two ways to use it. Neither needs git or a terminal. + +## In your browser (nothing to install) + +**[Open the QuantUI Viewer](https://the-schultz-lab.github.io/QuantUI/viewer/app/voici/render/viewer.html){ .md-button .md-button--primary }** + +1. Wait for the page to load. The first visit downloads Python into the + browser and can take a minute; later visits are faster. +2. It opens on a small set of demo results (water). To look at your own, + zip your results folder (right-click it → *Compress* / *Send to → + Compressed (zipped) folder*) and click **Upload .zip**. +3. Pick a calculation in the **History** tab, then **→ View Analysis**. + +Your files never leave your computer: the zip is unpacked inside the browser +tab and is gone when you close it. Use a recent Chrome, Edge, Firefox or +Safari. + +## Desktop app (works offline) + +Download the installer for your computer from the +[QuantUI releases page](https://github.com/The-Schultz-Lab/QuantUI/releases) +(under *Assets* of the latest release): + +| Computer | File | +| --- | --- | +| Windows | `QuantUI-Viewer--Windows-x86_64.exe` | +| Mac (Apple silicon) | `QuantUI-Viewer--MacOSX-arm64.pkg` | +| Linux | `QuantUI-Viewer--Linux-x86_64.sh` | + +The installers are **not code-signed**, so your computer warns you the first +time: + +- **Windows:** "Windows protected your PC" → click **More info**, then + **Run anyway**. Choose *Just Me* when asked (no admin rights needed). +- **Mac:** the installer is blocked the first time you open it. Open + **System Settings → Privacy & Security**, scroll down to the message about + the QuantUI installer, click **Open Anyway** and enter your password. +- **Linux:** run `bash QuantUI-Viewer-*.sh` in a terminal. + +Then start **QuantUI Viewer** from the Start menu / Desktop (Windows), the +Applications folder in your home folder (Mac), or your applications menu +(Linux). Your browser opens on the Viewer: type the path of your results +folder into **Results folder** and click **Open**. Click **Exit** when you are +done. + +## Not available yet + +- **Orbital isosurfaces, density and ESP surfaces** need PySCF to compute, + which the Viewer does not include. Use the full QuantUI for those. +- The browser Viewer shows molecules with py3Dmol only. diff --git a/mkdocs.yml b/mkdocs.yml index cb494ad..5a90193 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,6 +81,7 @@ nav: - Getting Started: - Installation: installation.md - Platform Support: platforms.md + - QuantUI Viewer: viewer.md - Tutorials: tutorials.md - User Guide: - Features: features.md diff --git a/packaging/viewer/README.md b/packaging/viewer/README.md new file mode 100644 index 0000000..50aba16 --- /dev/null +++ b/packaging/viewer/README.md @@ -0,0 +1,98 @@ +# QuantUI Viewer — lightweight History + Analysis + +A cut-down QuantUI for browsing results on any laptop: only the **History** and +**Analysis** tabs, pointed at a results folder. No quantum engine (PySCF) is +installed, so nothing here can run a calculation. + +It is the normal app in viewer mode — `QuantUIApp(viewer=True)` — so every +panel is the same code as the full app. From a dev install: + +```bash +quantui view path/to/results # or just `quantui view` and pick the folder in the app +``` + +Two ways to ship it, both built by +[`.github/workflows/viewer-builds.yml`](../../.github/workflows/viewer-builds.yml). +Where students get them: + +- **Browser build** — published with the docs by + [`pages.yml`](../../.github/workflows/pages.yml) at + `https://the-schultz-lab.github.io/QuantUI/viewer/app/voici/render/viewer.html` + (CDN Pyodide), linked from the docs page + [`docs/viewer.md`](../../docs/viewer.md) (`/viewer/`). Rebuilt on every + release and on docs / `packaging/viewer/` changes to `main`. +- **Installers** — attached to each GitHub Release by `viewer-builds.yml` + when the release is published. + +| | Desktop installer (`installer/`) | Browser build (`browser/`) | +| --- | --- | --- | +| User gets | `.exe` / `.pkg` / `.sh` plus a "QuantUI Viewer" shortcut | A web page, nothing installed | +| Results folder | Any folder on disk (type the path, **Open**) | Upload a `.zip` of it (**Upload .zip**) | +| Offline | Yes | Only if Pyodide is self-hosted (see below) | +| Size | ~240 MB download, ~1 GB installed (Linux build) | ~20–60 MB first load from CDN; ~550 MB site if self-hosted | +| Isosurfaces | Not available (needs PySCF) | Not available (needs PySCF) | +| Signing | **Unsigned** — see below | None needed | + +## Desktop installer + +[conda constructor](https://github.com/conda/constructor) bundles Python, +Voilà and the viewer's conda-forge dependencies; `post_install.*` installs the +QuantUI wheel and creates the shortcut (Desktop + Start menu on Windows, +`~/Applications/QuantUI Viewer.app` on macOS, a `.desktop` entry on Linux). The +shortcut runs `quantui view`, which starts Voilà on a free `127.0.0.1` port and +opens the browser. **Exit** in the app stops it. + +The installers are **not code-signed**. First run: + +- **Windows** — SmartScreen shows "Windows protected your PC": click + **More info → Run anyway**. Choose "Just Me" (no admin rights needed). +- **macOS 15+** — the `.pkg` is blocked the first time. Open **System Settings → + Privacy & Security**, scroll to the message about the installer, click + **Open Anyway**, and enter your password. Needs an admin account. The app the + installer creates is made on your Mac, not downloaded, so it should open + without a further prompt (not yet confirmed on a real Mac). +- **Linux** — `bash QuantUI-Viewer-*.sh` (needs a terminal once). + +Free signing for the Windows installer is available to open-source projects +through the [SignPath Foundation](https://signpath.org/); macOS notarization has +no free route. + +## Browser build + +[Voici](https://github.com/voila-dashboards/voici) turns the viewer notebook +into a static site whose Python kernel is Pyodide (CPython compiled to +WebAssembly). `build.sh` bundles the QuantUI wheel and its pure-Python +dependencies into the site, so nothing is fetched from PyPI at runtime. + +```bash +python packaging/viewer/browser/make_samples.py samples # demo results (needs PySCF) +packaging/viewer/browser/build.sh dist/quantui-*.whl samples site [pyodide-X.tar.bz2] +python -m http.server -d site 8000 # then open http://localhost:8000/voici/render/viewer.html +``` + +Serve it over HTTP (GitHub Pages works); `file://` cannot run it. Without a +Pyodide tarball the site loads Pyodide from the jsDelivr CDN; pass the tarball +from the [Pyodide releases](https://github.com/pyodide/pyodide/releases) to +self-host it (offline-capable after the first visit, but the full distribution +is ~550 MB unpruned). + +Browser differences, all handled in code: + +- **No threads.** `Thread.start()` raises in Pyodide, so background renders + (vibrational animation, isosurface, export) run inline instead + (`app_visualization._start_daemon`). +- **3D viewer is py3Dmol only.** plotlymol needs RDKit, which Pyodide lacks. +- **No Exit button** (there is no server to stop) and no local disk: results + arrive as an uploaded `.zip`, unpacked into the page's in-memory filesystem. + They are gone when the tab closes, and never leave the computer. + +Version pins in `build.sh` matter: `jupyterlite-pyodide-kernel` 0.7.2 does not +load under `voici` 0.10.0 (built against a newer JupyterLab); 0.7.0 does. + +## Known gaps + +- Orbital isosurfaces need PySCF to compute the cube file, so they are not + available in either build yet. Fix: have the full app save HOMO/LUMO cubes at + calculation time, or evaluate orbitals on a grid with numpy alone. +- Voilà's page template loads Font Awesome icons from a CDN; offline, the + button icons are missing (labels still show). diff --git a/packaging/viewer/browser/build.sh b/packaging/viewer/browser/build.sh new file mode 100755 index 0000000..4c183ee --- /dev/null +++ b/packaging/viewer/browser/build.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Build the browser (Voici + Pyodide) QuantUI Viewer as a static site. +# +# packaging/viewer/browser/build.sh WHEEL SAMPLES_DIR OUT_DIR [PYODIDE_TARBALL] +# +# WHEEL the quantui-*.whl to bundle +# SAMPLES_DIR result folders shipped as demo content (may be empty) +# OUT_DIR where the static site is written (serve it over HTTP; file:// +# cannot run WebAssembly workers) +# PYODIDE_TARBALL optional pyodide-X.tar.bz2 to self-host (offline-capable, +# ~550 MB site). Omitted: Pyodide loads from the jsDelivr CDN. +# +# Pins matter: jupyterlite-pyodide-kernel 0.7.2 is built against a newer +# JupyterLab than voici 0.10.0 ships and fails to load ("Shared module +# @jupyterlab/pluginmanager doesn't exist"); 0.7.0 matches. The xeus addon is +# disabled because the kernel is Pyodide, not xeus-python. +set -euo pipefail +WHEEL=$1 SAMPLES=$2 OUT=$3 TARBALL=${4:-} +HERE=$(cd "$(dirname "$0")" && pwd) +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +python -m pip install --quiet "voici==0.10.0" "jupyterlite-pyodide-kernel==0.7.0" \ + "jupyterlab_widgets==3.0.17" +mkdir -p "$WORK/content" "$WORK/wheels" +cp "$HERE/viewer.ipynb" "$WORK/content/" +mkdir -p "$WORK/content/sample_results" +cp -r "$SAMPLES"/. "$WORK/content/sample_results/" 2>/dev/null || true +cp "$WHEEL" "$WORK/wheels/" +python -m pip download --quiet --no-deps --only-binary=:all: --dest "$WORK/wheels" \ + --python-version 3.13 --platform any \ + plotly narwhals py3Dmol "ipywidgets==8.1.9" comm "jupyterlab_widgets==3.0.17" \ + "widgetsnbextension==4.0.16" + +ARGS=(--contents content --output-dir "$(realpath -m "$OUT")" --piplite-wheels wheels + --disable-addons jupyterlite-xeus) +if [ -n "$TARBALL" ]; then + ARGS+=(--PyodideAddon.pyodide_url="$(realpath "$TARBALL")") +fi +(cd "$WORK" && voici build "${ARGS[@]}") +echo "Built $(du -sh "$OUT" | cut -f1) site at $OUT — open voici/render/viewer.html" diff --git a/packaging/viewer/browser/make_samples.py b/packaging/viewer/browser/make_samples.py new file mode 100644 index 0000000..8334ad4 --- /dev/null +++ b/packaging/viewer/browser/make_samples.py @@ -0,0 +1,66 @@ +"""Write a small set of real QuantUI result folders (water, RHF/B3LYP) for the +browser build's demo content. Needs PySCF; takes ~15 s on a laptop CPU. + + python packaging/viewer/browser/make_samples.py OUT_DIR +""" + +from __future__ import annotations + +import io +import sys +from pathlib import Path + +from quantui.benchmarks import _save_calibration_step +from quantui.freq_calc import run_freq_calc +from quantui.molecule import Molecule +from quantui.optimizer import optimize_geometry +from quantui.results_storage import save_result +from quantui.session_calc import run_in_session +from quantui.tddft_calc import run_tddft_calc + + +def main(out: Path) -> None: + import os + + out.mkdir(parents=True, exist_ok=True) + os.environ["QUANTUI_RESULTS_DIR"] = str(out) + water = Molecule( + ["O", "H", "H"], [[0, 0, 0.117], [0, 0.757, -0.469], [0, -0.757, -0.469]] + ) + + def run(fn, calc_type, **kw): + log = io.StringIO() + res = fn(water, progress_stream=log, **kw) + # Same save sequence the app's own calibration runner uses. + _save_calibration_step( + res, + calc_type=calc_type, + pyscf_log=log.getvalue(), + calibration_run_id="", + mol=water, + ) + + run(run_in_session, "single_point", method="B3LYP", basis="6-31G") + run(optimize_geometry, "geometry_opt", method="RHF", basis="STO-3G") + run(run_freq_calc, "frequency", method="RHF", basis="STO-3G") + log = io.StringIO() + res = run_tddft_calc( + water, method="B3LYP", basis="6-31G", nstates=5, progress_stream=log + ) + save_result( + res, + pyscf_log=log.getvalue(), + calc_type="tddft", + molecule=water, + spectra={ + "uv_vis": { + "excitation_energies_ev": res.excitation_energies_ev, + "oscillator_strengths": res.oscillator_strengths, + "wavelengths_nm": res.wavelengths_nm(), + } + }, + ) + + +if __name__ == "__main__": + main(Path(sys.argv[1])) diff --git a/packaging/viewer/browser/viewer.ipynb b/packaging/viewer/browser/viewer.ipynb new file mode 100644 index 0000000..cae1137 --- /dev/null +++ b/packaging/viewer/browser/viewer.ipynb @@ -0,0 +1,35 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "quantui-viewer", + "metadata": {}, + "outputs": [], + "source": [ + "# QuantUI Viewer - browser build (Pyodide). Packages come from this site's\n", + "# bundled wheels; QuantUI itself is installed without its server-side deps\n", + "# (Jupyter, Voila, kaleido), which a browser kernel neither has nor needs.\n", + "import piplite\n", + "\n", + "await piplite.install(\n", + " [\"numpy\", \"matplotlib\", \"requests\", \"plotly\", \"narwhals\", \"py3dmol\", \"ipywidgets\"]\n", + ")\n", + "await piplite.install(\"quantui\", deps=False)\n", + "\n", + "import os\n", + "\n", + "os.environ.setdefault(\"QUANTUI_RESULTS_DIR\", \"sample_results\")\n", + "from quantui.app import QuantUIApp\n", + "\n", + "QuantUIApp(viewer=True).display()\n" + ] + } + ], + "metadata": { + "kernelspec": {"display_name": "Python (Pyodide)", "language": "python", "name": "python"}, + "language_info": {"name": "python"} + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/packaging/viewer/installer/construct.yaml b/packaging/viewer/installer/construct.yaml new file mode 100644 index 0000000..ec0702e --- /dev/null +++ b/packaging/viewer/installer/construct.yaml @@ -0,0 +1,38 @@ +# QuantUI Viewer desktop installer (conda constructor) — History + Analysis +# only, no quantum engine. Built by .github/workflows/viewer-builds.yml: +# +# QUANTUI_VERSION=0.9.0 constructor packaging/viewer/installer --output-dir out +# +# The QuantUI wheel (copied in next to this file by the workflow) is installed +# by post_install.* — QuantUI is not on conda-forge. Installers are UNSIGNED +# (no signing fee): see packaging/viewer/README.md for the one-time +# SmartScreen / Gatekeeper steps. +name: QuantUI-Viewer +version: {{ environ["QUANTUI_VERSION"] }} +company: The Schultz Lab +license_file: LICENSE +channels: + - conda-forge +specs: + - python 3.11.* + - pip + - voila >=0.5,<0.6 + - ipykernel + - ipywidgets >=8,<9 + - numpy + - matplotlib-base + - plotly + - py3dmol + - requests +extra_files: + - quantui-{{ environ["QUANTUI_VERSION"] }}-py3-none-any.whl + - create_shortcuts.ps1 # [win] +post_install: post_install.sh # [unix] +post_install: post_install.bat # [win] +installer_type: sh # [linux] +installer_type: pkg # [osx] +installer_type: exe # [win] +# Per-user install; never touch the user's shell, PATH or registry Python. +register_python: false +initialize_conda: false +initialize_by_default: false diff --git a/packaging/viewer/installer/create_shortcuts.ps1 b/packaging/viewer/installer/create_shortcuts.ps1 new file mode 100644 index 0000000..4be203a --- /dev/null +++ b/packaging/viewer/installer/create_shortcuts.ps1 @@ -0,0 +1,16 @@ +# Desktop + Start-menu "QuantUI Viewer" shortcuts -> \Scripts\quantui.exe view +# Run by post_install.bat; errors go to \post_install.log. +param([Parameter(Mandatory = $true)][string]$Prefix) +$ErrorActionPreference = 'Stop' +$shell = New-Object -ComObject WScript.Shell +foreach ($dir in @([Environment]::GetFolderPath('Desktop'), [Environment]::GetFolderPath('Programs'))) { + if (-not $dir) { continue } + New-Item -ItemType Directory -Force -Path $dir | Out-Null + $lnk = $shell.CreateShortcut((Join-Path $dir 'QuantUI Viewer.lnk')) + $lnk.TargetPath = Join-Path $Prefix 'Scripts\quantui.exe' + $lnk.Arguments = 'view' + $lnk.WorkingDirectory = $env:USERPROFILE + $lnk.Description = 'Browse QuantUI results (History + Analysis)' + $lnk.Save() + Write-Output "shortcut: $(Join-Path $dir 'QuantUI Viewer.lnk')" +} diff --git a/packaging/viewer/installer/post_install.bat b/packaging/viewer/installer/post_install.bat new file mode 100644 index 0000000..465afe0 --- /dev/null +++ b/packaging/viewer/installer/post_install.bat @@ -0,0 +1,12 @@ +@echo off +rem Install the bundled QuantUI wheel, then add Desktop + Start-menu shortcuts +rem that run "quantui view" (the console window is the server; closing it or +rem pressing Exit in the app stops QuantUI). Output: %PREFIX%\post_install.log +set "LOG=%PREFIX%\post_install.log" +for %%f in ("%PREFIX%\quantui-*.whl") do ( + "%PREFIX%\python.exe" -m pip install --no-deps --no-index --no-cache-dir "%%f" >> "%LOG%" 2>&1 || exit /b 1 + del "%%f" +) +"%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" -NoProfile -ExecutionPolicy Bypass -File "%PREFIX%\create_shortcuts.ps1" -Prefix "%PREFIX%" >> "%LOG%" 2>&1 +rem A missing shortcut should not fail the install; the log records why. +exit /b 0 diff --git a/packaging/viewer/installer/post_install.sh b/packaging/viewer/installer/post_install.sh new file mode 100755 index 0000000..f89f08a --- /dev/null +++ b/packaging/viewer/installer/post_install.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Install the bundled QuantUI wheel, then add a "QuantUI Viewer" launcher: +# a .desktop entry on Linux, an .app bundle in ~/Applications on macOS. +set -euo pipefail +"$PREFIX/bin/python" -m pip install --no-deps --no-index --no-cache-dir "$PREFIX"/quantui-*.whl +rm -f "$PREFIX"/quantui-*.whl + +if [ "$(uname)" = "Darwin" ]; then + APP="$HOME/Applications/QuantUI Viewer.app" + mkdir -p "$APP/Contents/MacOS" + cat > "$APP/Contents/MacOS/QuantUI Viewer" < "$APP/Contents/Info.plist" < + + + CFBundleNameQuantUI Viewer + CFBundleIdentifierorg.schultzlab.quantui.viewer + CFBundleExecutableQuantUI Viewer + CFBundlePackageTypeAPPL + +PLIST +else + APPS="${XDG_DATA_HOME:-$HOME/.local/share}/applications" + mkdir -p "$APPS" + cat > "$APPS/quantui-viewer.desktop" < None: + def __init__( + self, *, viewer: bool = False, results_dir: Optional[str | Path] = None + ) -> None: + # ── Mode ────────────────────────────────────────────────────────── + # viewer=True: History + Analysis only, for browsing a results + # folder with no quantum engine (see quantui/app_viewer.py). + self._viewer_mode: bool = viewer + if results_dir is not None: + _viewer_set_results_dir(Path(results_dir).expanduser().resolve()) # ── Instance state ──────────────────────────────────────────────── self._molecule: Optional[Molecule] = None self._last_result: Any = None @@ -1945,7 +1956,15 @@ def __init__(self) -> None: # Kick off slow startup work (GPU detection, History/Compare loading) # off the synchronous construction path so the UI paints fast. - self._start_deferred_startup_tasks() + if self._viewer_mode: + # Browsing only: no GPU probe, resume offers, or SLURM checks. + loop = self._get_kernel_io_loop() + if loop is not None: + loop.add_callback(self._refresh_results_browser) + else: + self._refresh_results_browser() + else: + self._start_deferred_startup_tasks() def _start_deferred_startup_tasks(self) -> None: """Run slow startup work AFTER widget construction so it doesn't block @@ -2045,6 +2064,11 @@ def display(self) -> None: self._exit_output, self._theme_style, self.help_tab_panel, + *( + [self._viewer_folder_bar] + if getattr(self, "_viewer_mode", False) + else [] + ), self.root_tab, ] ) @@ -2074,6 +2098,8 @@ def _build_widgets(self) -> None: self._build_files_tab() self._build_help_section() self._build_issue_widgets() + if self._viewer_mode: + _viewer_build_folder_bar(self, layout_fn=_layout) # ── Theme selector ──────────────────────────────────────────────────── @@ -2142,7 +2168,10 @@ def _activity_pulse( kwargs={"kind": kind}, ) timer.daemon = True - timer.start() + try: + timer.start() + except RuntimeError: # no threads (browser/Pyodide viewer build) + self._activity_end(kind=kind) def _on_root_tab_changed(self, change) -> None: """Pulse the activity light on tab navigation actions.""" @@ -2519,6 +2548,8 @@ def _build_issue_widgets(self) -> None: # ── Tab assembly (Cell 10) ──────────────────────────────────────────── def _root_tab_order(self) -> list[str]: + if getattr(self, "_viewer_mode", False): + return ["history", "analysis"] order = ["calculate"] if _slurm_jobs_tab_visible(self): order.append("slurm_jobs") diff --git a/quantui/app_history.py b/quantui/app_history.py index 4e2a05d..84ded8c 100644 --- a/quantui/app_history.py +++ b/quantui/app_history.py @@ -322,9 +322,15 @@ def on_past_dd_changed(app: Any, change: dict[str, Any], *, layout_fn: Any) -> N app._history_load_analysis(rd, source_btns=(br, ba)) ) ) + # Viewer mode has no Results tab — Analysis is the only destination. + nav_btns = ( + [btn_analysis] + if getattr(app, "_viewer_mode", False) + else [btn_results, btn_analysis] + ) display( widgets.HBox( - [btn_results, btn_analysis], + nav_btns, layout=layout_fn(gap="8px", margin="6px 0 0"), ) ) @@ -539,7 +545,7 @@ def history_load_results( with timer.stage("apply_analysis_context"): app._apply_analysis_context(ctx) with timer.stage("nav_tab"): - app.root_tab.selected_index = 1 + app.root_tab.selected_index = app._tab_index("results") except Exception: status = "error" raise @@ -606,7 +612,7 @@ def history_load_analysis( app._apply_analysis_context(ctx) with timer.stage("nav_tab"): - app.root_tab.selected_index = 2 + app.root_tab.selected_index = app._tab_index("analysis") except Exception: status = "error" raise diff --git a/quantui/app_launcher.py b/quantui/app_launcher.py index 94f86e6..6c62804 100644 --- a/quantui/app_launcher.py +++ b/quantui/app_launcher.py @@ -23,6 +23,7 @@ DEFAULT_APP_PORT = 8867 APP_NOTEBOOK_NAME = "app.ipynb" +VIEWER_NOTEBOOK_NAME = "viewer.ipynb" HOME_LAUNCHER_NOTEBOOK_NAME = "QuantUI.ipynb" LAUNCHER_SCRIPT_NAME = "quantui-app" @@ -71,6 +72,18 @@ def quantui_home() -> Path: return Path.home() / ".quantui" +def _viewer_notebook() -> dict: + """The app notebook with the display cell switched to viewer mode.""" + nb: dict = json.loads(json.dumps(_APP_NOTEBOOK)) + nb["cells"][0]["source"] = ["# QuantUI Viewer\n"] + nb["cells"][1]["source"] = [ + "from quantui.app import QuantUIApp\n", + "\n", + "QuantUIApp(viewer=True).display()\n", + ] + return nb + + def app_notebook_path() -> Path: """Path to the generated Voilà launcher notebook.""" return quantui_home() / APP_NOTEBOOK_NAME @@ -153,6 +166,15 @@ def ensure_app_notebook(*, force: bool = False) -> Path: return path +def ensure_viewer_notebook() -> Path: + """Write ``~/.quantui/viewer.ipynb`` (always refreshed — it is generated).""" + home = quantui_home() + home.mkdir(parents=True, exist_ok=True) + path = home / VIEWER_NOTEBOOK_NAME + path.write_text(json.dumps(_viewer_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() @@ -286,6 +308,84 @@ def run_voila_app( return 130 +def find_free_port() -> int: + """Return a free localhost TCP port (so the viewer never collides with a + running QuantUI on 8867 or a second viewer).""" + import socket + + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +def _wait_for_port(port: int, proc: subprocess.Popen, timeout_s: float) -> bool: + """Poll until Voilà accepts connections on *port* (or the process dies).""" + import socket + + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if proc.poll() is not None: + return False + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.5): + return True + except OSError: + time.sleep(0.25) + return False + + +def run_viewer_app( + results_dir: Optional[Path] = None, + *, + port: Optional[int] = None, + open_browser: bool = True, +) -> int: + """Start the History + Analysis viewer on *results_dir* (lightweight mode). + + Needs only the ``[app]`` extra — no quantum engine. The folder reaches the + kernel through ``QUANTUI_RESULTS_DIR`` (Voilà kernels inherit the server's + environment); with no folder the viewer opens on the user's home directory + and the in-app folder bar picks the real one. + """ + voila = voila_executable() + if voila is None: + print(voila_missing_message(), file=sys.stderr) + return 1 + + env = dict(os.environ) + if results_dir is not None: + folder = Path(results_dir).expanduser().resolve() + if not folder.is_dir(): + print(f"Not a folder: {folder}", file=sys.stderr) + return 1 + env["QUANTUI_RESULTS_DIR"] = str(folder) + elif not env.get("QUANTUI_RESULTS_DIR"): + env["QUANTUI_RESULTS_DIR"] = str(Path.home()) + + port = port or find_free_port() + notebook = ensure_viewer_notebook() + argv = build_voila_argv(notebook, port=port, no_browser=True) + argv[0] = voila + argv.append("--Voila.ip=127.0.0.1") + + url = f"http://127.0.0.1:{port}" + print(f"Starting QuantUI Viewer at {url}") + print(f"Results folder: {env['QUANTUI_RESULTS_DIR']}") + proc = subprocess.Popen(argv, env=env) + if open_browser and _wait_for_port(port, proc, timeout_s=60): + _open_url_best_effort(url) + print("Use the Exit button in the app (or Ctrl-C here) 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 diff --git a/quantui/app_viewer.py b/quantui/app_viewer.py new file mode 100644 index 0000000..f2e9eae --- /dev/null +++ b/quantui/app_viewer.py @@ -0,0 +1,179 @@ +"""Viewer mode — History + Analysis only, pointed at a results folder. + +``QuantUIApp(viewer=True)`` builds the normal app but shows only the History +and Analysis tabs, skips calc-related startup work (GPU probe, resume offers, +SLURM checks), and adds a folder bar so the user can point the History browser +at any QuantUI results folder. No quantum engine is needed to browse results. + +The results folder is selected through ``QUANTUI_RESULTS_DIR`` — the same seam +``results_storage._default_results_dir`` already reads on every call (and the +Apptainer image sets), so every History/Compare/Files code path follows it +without being threaded a new argument. + +In a browser (WebAssembly/Pyodide) build there is no local disk to point at, +so the bar also offers a ``.zip`` upload: the archive is unpacked into the +browser's in-memory filesystem and opened like any other folder. +""" + +from __future__ import annotations + +import html as _html +import io +import os +import sys +import zipfile +from pathlib import Path +from typing import Any, Optional + +import ipywidgets as widgets + +from . import theme as _theme + +RESULTS_DIR_ENV = "QUANTUI_RESULTS_DIR" +IN_BROWSER = sys.platform == "emscripten" +UPLOAD_ROOT = Path("uploaded_results") + + +def set_results_dir(path: Path) -> None: + """Point every results reader at *path* (process-wide).""" + os.environ[RESULTS_DIR_ENV] = str(path) + + +def resolve_results_folder(text: str) -> tuple[Optional[Path], str]: + """Validate a user-typed folder path. + + Returns ``(path, "")`` on success or ``(None, reason)``. A folder holding + ``result.json`` directly is one result, not a results folder — its parent + is used instead so a user who picks a single run still sees it listed. + """ + raw = (text or "").strip().strip('"').strip("'") + if not raw: + return None, "Enter a folder path." + path = Path(raw).expanduser() + try: + path = path.resolve() + except OSError as exc: + return None, f"Could not read that path: {exc}" + if not path.is_dir(): + return None, "That folder does not exist." + if (path / "result.json").is_file(): + path = path.parent + return path, "" + + +def build_viewer_folder_bar(app: Any, *, layout_fn: Any) -> None: + """Build the results-folder picker shown above the tabs in viewer mode.""" + from quantui.results_storage import _default_results_dir + + app._viewer_folder_txt = widgets.Text( + value=str(_default_results_dir().expanduser().resolve()), + placeholder="Path to a QuantUI results folder", + description="Results folder:", + style={"description_width": "110px"}, + layout=layout_fn(width="640px"), + ) + app._viewer_open_btn = widgets.Button( + description="Open", + icon="folder-open", + button_style="primary", + tooltip="Load the calculations saved in this folder", + layout=layout_fn(width="90px"), + ) + app._viewer_status_html = widgets.HTML("") + app._viewer_open_btn.on_click(lambda _btn: on_open_folder(app)) + children = [app._viewer_folder_txt, app._viewer_open_btn] + if IN_BROWSER: + app._viewer_upload = widgets.FileUpload( + accept=".zip", + multiple=False, + description="Upload .zip", + tooltip="Upload a zipped QuantUI results folder", + layout=layout_fn(width="130px"), + ) + app._viewer_upload.observe( + lambda change: on_upload_zip(app, change["new"]), names="value" + ) + children.append(app._viewer_upload) + # No local server to stop — Exit would only kill the browser kernel. + app._exit_btn.layout.display = "none" + children.append(app._viewer_status_html) + # The header advertises a System Settings tab the viewer does not have. + app._welcome_html.value = app._welcome_html.value.replace( + "System Settings tab for environment + calibration", + "Viewer — browse saved results", + ) + app._viewer_folder_bar = widgets.HBox( + children, + layout=layout_fn(align_items="center", gap="8px", margin="0 0 8px"), + ) + + +def extract_results_zip(data: bytes, name: str, root: Path = UPLOAD_ROOT) -> Path: + """Unpack a zipped results folder under *root*; return the folder to open. + + Rejects members that would escape the destination (``..`` or absolute + paths). When the archive wraps everything in one top-level folder, that + folder is returned so History sees the result directories directly. + """ + dest = (root / Path(name).stem).resolve() + dest.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(io.BytesIO(data)) as zf: + for member in zf.namelist(): + target = (dest / member).resolve() + if dest != target and dest not in target.parents: + raise ValueError(f"Unsafe path in archive: {member}") + zf.extractall(dest) + entries = [p for p in dest.iterdir() if not p.name.startswith(("__MACOSX", "."))] + if ( + len(entries) == 1 + and entries[0].is_dir() + and not (entries[0] / "result.json").is_file() + ): + return entries[0] + return dest + + +def on_upload_zip(app: Any, value: Any) -> None: + """Unpack an uploaded results zip and open it.""" + if not value: + return + item = value[0] + try: + folder = extract_results_zip(bytes(item["content"]), item["name"]) + except (zipfile.BadZipFile, ValueError, OSError) as exc: + _set_status(app, f"Could not read {item['name']}: {exc}", error=True) + return + app._viewer_folder_txt.value = str(folder) + on_open_folder(app) + + +def on_open_folder(app: Any) -> None: + """Switch the History browser to the folder typed in the folder bar.""" + path, reason = resolve_results_folder(app._viewer_folder_txt.value) + if path is None: + _set_status(app, reason, error=True) + return + set_results_dir(path) + app._viewer_folder_txt.value = str(path) + app._deactivate_all_ana_panels() + app._refresh_results_browser() + from quantui.results_storage import list_results + + n = len(list_results(path)) + _set_status( + app, + ( + f"{n} calculation{'s' if n != 1 else ''} found." + if n + else "No QuantUI results found in this folder." + ), + error=not n, + ) + app.root_tab.selected_index = app._tab_index("history") + + +def _set_status(app: Any, text: str, *, error: bool) -> None: + color = _theme.css.ACCENT_ERROR if error else _theme.css.TEXT_SECONDARY + app._viewer_status_html.value = ( + f'{_html.escape(text)}' + ) diff --git a/quantui/app_visualization.py b/quantui/app_visualization.py index 1c245ea..1227027 100644 --- a/quantui/app_visualization.py +++ b/quantui/app_visualization.py @@ -27,6 +27,21 @@ logger = logging.getLogger(__name__) +def _start_daemon(target: Any, *args: Any, **kwargs: Any) -> bool: + """Run *target* on a daemon thread; return False if it ran inline instead. + + Browser (Pyodide/WebAssembly) builds of the viewer have no threads — + ``Thread.start`` raises ``RuntimeError`` — so fall back to a synchronous + call there. On normal Python this is exactly ``Thread(...).start()``. + """ + try: + threading.Thread(target=target, args=args, kwargs=kwargs, daemon=True).start() + except RuntimeError: + target(*args, **kwargs) + return False + return True + + @contextmanager def _viz_render_event(app: Any, task: Any, backend: Any, **extras: Any): """Lifecycle telemetry context manager for one render-path execution. @@ -446,7 +461,7 @@ def _do_export() -> None: finally: app._queue_main_thread_callback(setattr, _btn, "disabled", False) - threading.Thread(target=_do_export, daemon=True).start() + _start_daemon(_do_export) export_btn.on_click(_on_export) @@ -731,12 +746,9 @@ def show_vib_animation(app: Any, freq_result: Any, molecule: Any) -> bool: _VIB_CAMERA_RESET_JS + f'

' f"⏳ Rendering vibrational animation ({first_label})…

", ) - threading.Thread( - target=app._render_vib_mode, - args=(vib_data, molecule, first_mode), - kwargs={"render_token": token}, - daemon=True, - ).start() + _start_daemon( + app._render_vib_mode, vib_data, molecule, first_mode, render_token=token + ) return True @@ -1618,8 +1630,9 @@ def _show_timeout() -> None: app._queue_main_thread_callback(_show_timeout) - threading.Thread(target=_run, daemon=True).start() - threading.Thread(target=_watchdog, daemon=True).start() + # No watchdog when _run already finished inline (no-thread runtimes). + if _start_daemon(_run): + _start_daemon(_watchdog) def on_orb_range_changed(app: Any, _change: Any = None) -> None: @@ -2972,12 +2985,9 @@ def on_vib_mode_changed(app: Any, change: dict[str, Any]) -> None: f'

' f"⏳ Rendering vibrational animation ({label})…

", ) - threading.Thread( - target=app._render_vib_mode, - args=(vib_data, molecule, mode_number), - kwargs={"render_token": token}, - daemon=True, - ).start() + _start_daemon( + app._render_vib_mode, vib_data, molecule, mode_number, render_token=token + ) _STEPPER_BTN_STYLE = ( @@ -4376,4 +4386,4 @@ def _done() -> None: app._queue_main_thread_callback(_done) - threading.Thread(target=_work, daemon=True).start() + _start_daemon(_work) diff --git a/quantui/cli.py b/quantui/cli.py index 0882b6c..9dd8a76 100644 --- a/quantui/cli.py +++ b/quantui/cli.py @@ -19,6 +19,9 @@ * ``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 view [FOLDER] [--port PORT] [--no-browser]`` — open the + lightweight History + Analysis viewer on a results folder (no quantum + engine needed; requires the ``[app]`` extra). * ``quantui setup [--force]`` — write ``~/.quantui/app.ipynb`` and a ``quantui-app`` shell shortcut under ``~/.local/bin``. * ``quantui submit REQUEST_JSON [REQUEST_JSON ...] [--dry-run] [--cores N] @@ -272,6 +275,17 @@ def _cmd_run_app(args: argparse.Namespace) -> int: ) +def _cmd_view(args: argparse.Namespace) -> int: + """Start the History + Analysis viewer on a results folder.""" + from quantui.app_launcher import run_viewer_app + + return run_viewer_app( + Path(args.folder) if args.folder else None, + port=args.port, + open_browser=not args.no_browser, + ) + + def _cmd_setup(args: argparse.Namespace) -> int: """Write ~/.quantui/app.ipynb and a quantui-app shell shortcut.""" from quantui.app_launcher import run_setup @@ -564,6 +578,31 @@ def _build_parser() -> argparse.ArgumentParser: ) run_app.set_defaults(func=_cmd_run_app) + view_parser = sub.add_parser( + "view", + help="Open the History + Analysis viewer on a results folder.", + ) + view_parser.add_argument( + "folder", + nargs="?", + default=None, + metavar="FOLDER", + help="QuantUI results folder (default: pick it in the app).", + ) + view_parser.add_argument( + "--port", + type=int, + default=None, + metavar="PORT", + help="TCP port for Voilà (default: a free port).", + ) + view_parser.add_argument( + "--no-browser", + action="store_true", + help="Do not open the browser automatically.", + ) + view_parser.set_defaults(func=_cmd_view) + submit_parser = sub.add_parser( "submit", help=( diff --git a/tests/test_app_viewer.py b/tests/test_app_viewer.py new file mode 100644 index 0000000..ffd9061 --- /dev/null +++ b/tests/test_app_viewer.py @@ -0,0 +1,158 @@ +"""Viewer mode: History + Analysis only, pointed at a results folder.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + +from quantui import app_launcher, cli +from quantui.app import QuantUIApp +from quantui.app_viewer import RESULTS_DIR_ENV, resolve_results_folder +from quantui.results_storage import save_result + + +def _save(results_dir, formula="H2O"): + res = SimpleNamespace( + formula=formula, + method="RHF", + basis="STO-3G", + energy_hartree=-74.96, + converged=True, + n_iterations=7, + homo_lumo_gap_ev=None, + ) + return save_result(res, results_dir=results_dir) + + +@pytest.fixture +def results_env(tmp_path, monkeypatch): + # setenv (not delenv) so monkeypatch restores the variable even after + # viewer code rewrites os.environ directly. + monkeypatch.setenv(RESULTS_DIR_ENV, str(tmp_path)) + return tmp_path + + +class TestResolveResultsFolder: + def test_empty(self): + assert resolve_results_folder(" ") == (None, "Enter a folder path.") + + def test_missing(self, tmp_path): + path, reason = resolve_results_folder(str(tmp_path / "nope")) + assert path is None and "does not exist" in reason + + def test_strips_quotes(self, tmp_path): + path, _ = resolve_results_folder(f'"{tmp_path}"') + assert path == tmp_path.resolve() + + def test_single_result_dir_maps_to_parent(self, tmp_path): + rd = _save(tmp_path) + path, _ = resolve_results_folder(str(rd)) + assert path == tmp_path.resolve() + + +class TestViewerApp: + def test_tabs_are_history_and_analysis(self, results_env, monkeypatch): + monkeypatch.setenv(RESULTS_DIR_ENV, str(results_env)) + app = QuantUIApp(viewer=True) + titles = [app.root_tab.get_title(i) for i in range(len(app.root_tab.children))] + assert titles == ["History", "Analysis"] + assert app._tab_index("analysis") == 1 + assert "System Settings" not in app._welcome_html.value + + def test_full_app_unchanged(self, results_env, monkeypatch): + monkeypatch.setenv(RESULTS_DIR_ENV, str(results_env)) + app = QuantUIApp() + assert app._root_tab_order()[0] == "calculate" + assert not hasattr(app, "_viewer_folder_bar") + + def test_results_dir_argument_lists_results(self, results_env, monkeypatch): + _save(results_env) + app = QuantUIApp(viewer=True, results_dir=results_env) + paths = [v for _, v in app.past_dd.options if v] + assert len(paths) == 1 + + def test_open_folder_switches_history(self, results_env, monkeypatch, tmp_path): + monkeypatch.setenv(RESULTS_DIR_ENV, str(results_env)) + app = QuantUIApp(viewer=True) + assert not [v for _, v in app.past_dd.options if v] + other = tmp_path / "other" + other.mkdir() + _save(other) + _save(other, formula="NH3") + app._viewer_folder_txt.value = str(other) + app._viewer_open_btn.click() + assert len([v for _, v in app.past_dd.options if v]) == 2 + assert "2 calculations found" in app._viewer_status_html.value + + def test_open_folder_bad_path_reports_error(self, results_env, monkeypatch): + monkeypatch.setenv(RESULTS_DIR_ENV, str(results_env)) + app = QuantUIApp(viewer=True) + app._viewer_folder_txt.value = str(results_env / "missing") + app._viewer_open_btn.click() + assert "does not exist" in app._viewer_status_html.value + + +class TestViewerLauncher: + def test_viewer_notebook_uses_viewer_mode(self, tmp_path, monkeypatch): + monkeypatch.setenv("QUANTUI_HOME", str(tmp_path)) + nb = json.loads(app_launcher.ensure_viewer_notebook().read_text()) + src = "".join(nb["cells"][1]["source"]) + assert "QuantUIApp(viewer=True).display()" in src + # The student app notebook is untouched. + assert "viewer" not in "".join(app_launcher._APP_NOTEBOOK["cells"][1]["source"]) + + def test_find_free_port(self): + assert 0 < app_launcher.find_free_port() < 65536 + + def test_missing_folder_fails(self, tmp_path, monkeypatch): + monkeypatch.setattr(app_launcher, "voila_executable", lambda: "voila") + assert app_launcher.run_viewer_app(tmp_path / "missing") == 1 + + def test_cli_view_parses(self, monkeypatch, tmp_path): + seen = {} + + def fake_run(folder, *, port, open_browser): + seen.update(folder=folder, port=port, open_browser=open_browser) + return 0 + + monkeypatch.setattr(app_launcher, "run_viewer_app", fake_run) + assert cli.main(["view", str(tmp_path), "--no-browser", "--port", "9001"]) == 0 + assert seen == {"folder": tmp_path, "port": 9001, "open_browser": False} + + +class TestExtractResultsZip: + @staticmethod + def _zip(files): + import io + import zipfile + + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, data in files.items(): + zf.writestr(name, data) + return buf.getvalue() + + def test_wrapper_folder_is_unwrapped(self, tmp_path): + from quantui.app_viewer import extract_results_zip + + data = self._zip({"results/run1/result.json": "{}"}) + folder = extract_results_zip(data, "results.zip", root=tmp_path) + assert folder.name == "results" + assert (folder / "run1" / "result.json").is_file() + + def test_flat_archive_returns_destination(self, tmp_path): + from quantui.app_viewer import extract_results_zip + + data = self._zip({"run1/result.json": "{}", "run2/result.json": "{}"}) + folder = extract_results_zip(data, "mine.zip", root=tmp_path) + assert folder == (tmp_path / "mine").resolve() + + def test_path_traversal_rejected(self, tmp_path): + from quantui.app_viewer import extract_results_zip + + data = self._zip({"../evil.txt": "x"}) + with pytest.raises(ValueError, match="Unsafe path"): + extract_results_zip(data, "bad.zip", root=tmp_path / "up") + assert not (tmp_path / "evil.txt").exists()